diff --git a/internal/seed/catalog.go b/internal/seed/catalog.go index 13f84e4..b8f6e08 100644 --- a/internal/seed/catalog.go +++ b/internal/seed/catalog.go @@ -2,7 +2,6 @@ package seed import ( "context" - "os" "sort" "strings" @@ -90,16 +89,19 @@ func observedVariables(sources []ObservedSource) []owl.DotenvVariable { } func dotenvVariables(opts Options) ([]owl.DotenvVariable, error) { - envFiles, err := filesOrDefaults(opts.WorkDir, opts.EnvFiles, ".env") + envFiles, err := filesOrDefaults(opts.WorkDir, opts.EnvFiles, defaultEnvFiles...) if err != nil { return nil, err } var vars []owl.DotenvVariable for _, file := range envFiles { - raw, err := os.ReadFile(file) + raw, ok, err := readOptionalEnvFile(file) if err != nil { return nil, err } + if !ok { + continue + } parsed, err := dotenv.ParseDotenvValues(raw) if err != nil { return nil, err diff --git a/internal/seed/files.go b/internal/seed/files.go index bd89fcb..10ccf2c 100644 --- a/internal/seed/files.go +++ b/internal/seed/files.go @@ -9,6 +9,8 @@ import ( "github.com/runmedev/owl/internal/requirements" ) +var defaultEnvFiles = []string{".env", ".env.local", ".env.development", ".env.dev"} + func resolveConfigPath(workDir string, explicit string, required bool) (string, error) { if explicit != "" { path := pathInWorkDir(workDir, explicit) @@ -86,6 +88,17 @@ func filesOrDefaults(workDir string, files []string, defaults ...string) ([]stri return existing, nil } +func readOptionalEnvFile(path string) ([]byte, bool, error) { + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return raw, true, nil +} + func pathInWorkDir(workDir string, path string) string { if workDir == "" || workDir == "." || filepath.IsAbs(path) { return path diff --git a/internal/seed/seed_test.go b/internal/seed/seed_test.go index 55f1fb3..6006ece 100644 --- a/internal/seed/seed_test.go +++ b/internal/seed/seed_test.go @@ -85,6 +85,113 @@ func TestNewStoreAttributesMatchingObservedEnvToDirenv(t *testing.T) { assert.Equal(t, owl.Source{Name: ".envrc", Kind: "direnv"}, env.Source) } +func TestNewStoreSkipsMissingEnvFiles(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + envFile := filepath.Join(dir, ".env") + require.NoError(t, os.WriteFile(envFile, []byte("PRESENT=from-dotenv\n"), 0o600)) + + result, err := NewStore(context.Background(), Options{ + WorkDir: dir, + EnvFiles: []string{".env.local", ".env"}, + Observed: []ObservedSource{ + {Source: owl.Source{Name: "[process]", Kind: "process"}}, + }, + }) + require.NoError(t, err) + + dotenvVars := result.Catalog.DotenvResolverInput() + require.Len(t, dotenvVars, 1) + assert.Equal(t, "PRESENT", dotenvVars[0].Key) + assert.Equal(t, "from-dotenv", dotenvVars[0].Value) + assert.Equal(t, owl.Source{Name: envFile, Kind: "dotenv"}, dotenvVars[0].Source) +} + +func TestNewStoreUsesDefaultEnvFilesInPrecedenceOrder(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("DEFAULT_ORDER=from-env\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env.local"), []byte("DEFAULT_ORDER=from-env-local\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env.development"), []byte("DEFAULT_ORDER=from-env-development\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env.dev"), []byte("DEFAULT_ORDER=from-env-dev\n"), 0o600)) + + result, err := NewStore(context.Background(), Options{ + WorkDir: dir, + Observed: []ObservedSource{ + {Source: owl.Source{Name: "[process]", Kind: "process"}}, + }, + }) + require.NoError(t, err) + + dotenvVars := result.Catalog.DotenvResolverInput() + require.Len(t, dotenvVars, 4) + assert.Equal(t, "from-env-dev", dotenvVars[0].Value) + assert.Equal(t, ".env.dev", filepath.Base(dotenvVars[0].Source.Name)) + assert.Equal(t, "from-env-development", dotenvVars[1].Value) + assert.Equal(t, ".env.development", filepath.Base(dotenvVars[1].Source.Name)) + assert.Equal(t, "from-env-local", dotenvVars[2].Value) + assert.Equal(t, ".env.local", filepath.Base(dotenvVars[2].Source.Name)) + assert.Equal(t, "from-env", dotenvVars[3].Value) + assert.Equal(t, ".env", filepath.Base(dotenvVars[3].Source.Name)) +} + +func TestNewRawValueStoreSkipsMissingEnvFiles(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("PRESENT=from-dotenv\n"), 0o600)) + + store, err := NewRawValueStore(Options{ + WorkDir: dir, + EnvFiles: []string{".env.local", ".env"}, + }, false) + require.NoError(t, err) + + items, err := store.Snapshot(owl.SnapshotPolicy{Reveal: true}) + require.NoError(t, err) + env := snapshotItemByName(items)["PRESENT"] + assert.Equal(t, "from-dotenv", env.Value) + assert.Equal(t, owl.VisibilityLiteral, env.Visibility) + assert.Equal(t, "dotenv", env.Source.Kind) + assert.Equal(t, ".env", filepath.Base(env.Source.Name)) +} + +func TestNewRawValueStoreUsesDefaultEnvFilesInOrder(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("DEFAULT_ORDER=from-env\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env.local"), []byte("DEFAULT_ORDER=from-env-local\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env.development"), []byte("DEFAULT_ORDER=from-env-development\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env.dev"), []byte("DEFAULT_ORDER=from-env-dev\n"), 0o600)) + + store, err := NewRawValueStore(Options{WorkDir: dir}, false) + require.NoError(t, err) + + items, err := store.Snapshot(owl.SnapshotPolicy{Reveal: true}) + require.NoError(t, err) + env := snapshotItemByName(items)["DEFAULT_ORDER"] + assert.Equal(t, "from-env-dev", env.Value) + assert.Equal(t, ".env.dev", filepath.Base(env.Source.Name)) +} + +func TestDotenvVariablesReturnsEnvFileReadErrorsExceptMissing(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + notFile := filepath.Join(dir, "not-a-file") + require.NoError(t, os.Mkdir(notFile, 0o700)) + + _, err := dotenvVariables(Options{ + WorkDir: dir, + EnvFiles: []string{".env.local", "not-a-file"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not-a-file") +} + func TestProcessEnvDotenvQuotesValuesAndSkipsInvalidKeys(t *testing.T) { t.Parallel() diff --git a/internal/seed/store.go b/internal/seed/store.go index d7f9bf8..3c433f1 100644 --- a/internal/seed/store.go +++ b/internal/seed/store.go @@ -70,16 +70,19 @@ func newBaseStore(req baseStoreRequest) (*owl.Store, error) { } if req.loadValues { - envFiles, err := filesOrDefaults(opts.WorkDir, opts.EnvFiles, ".env") + envFiles, err := filesOrDefaults(opts.WorkDir, opts.EnvFiles, defaultEnvFiles...) if err != nil { return nil, err } storeOpts = append(storeOpts, owl.WithDotenv("[process]", strings.NewReader(processEnvDotenv(flattenObservedEnviron(opts.Observed))))) for _, file := range envFiles { - raw, err := os.ReadFile(file) + raw, ok, err := readOptionalEnvFile(file) if err != nil { return nil, err } + if !ok { + continue + } storeOpts = append(storeOpts, owl.WithDotenv(file, bytes.NewReader(raw))) } } diff --git a/pkg/owl/seed/seed_test.go b/pkg/owl/seed/seed_test.go index 6912d7f..fba6875 100644 --- a/pkg/owl/seed/seed_test.go +++ b/pkg/owl/seed/seed_test.go @@ -34,3 +34,55 @@ func TestNewStoreReturnsSeededStore(t *testing.T) { assert.Equal(t, "API_KEY", items[0].Name) assert.Equal(t, "secret", items[0].Value) } + +func TestNewStoreSkipsMissingEnvFiles(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + envFile := filepath.Join(dir, ".env") + require.NoError(t, os.WriteFile(envFile, []byte("PRESENT=from-dotenv\n"), 0o600)) + + result, err := NewStore(context.Background(), Options{ + WorkDir: dir, + EnvFiles: []string{".env.local", ".env"}, + Observed: []ObservedSource{ + {Source: owl.Source{Name: "[process]", Kind: "process"}}, + }, + }) + require.NoError(t, err) + require.Empty(t, result.Diagnostics) + + items, err := result.Store.Snapshot(owl.SnapshotPolicy{Reveal: true}) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "PRESENT", items[0].Name) + assert.Equal(t, "from-dotenv", items[0].Value) + assert.Equal(t, "dotenv", items[0].Source.Kind) + assert.Equal(t, ".env", filepath.Base(items[0].Source.Name)) +} + +func TestNewStoreUsesDefaultEnvFilesInOrder(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("DEFAULT_ORDER=from-env\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env.local"), []byte("DEFAULT_ORDER=from-env-local\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env.development"), []byte("DEFAULT_ORDER=from-env-development\n"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env.dev"), []byte("DEFAULT_ORDER=from-env-dev\n"), 0o600)) + + result, err := NewStore(context.Background(), Options{ + WorkDir: dir, + Observed: []ObservedSource{ + {Source: owl.Source{Name: "[process]", Kind: "process"}}, + }, + }) + require.NoError(t, err) + require.Empty(t, result.Diagnostics) + + items, err := result.Store.Snapshot(owl.SnapshotPolicy{Reveal: true}) + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "DEFAULT_ORDER", items[0].Name) + assert.Equal(t, "from-env-dev", items[0].Value) + assert.Equal(t, ".env.dev", filepath.Base(items[0].Source.Name)) +}