From 1a721af293f58bfb5c614d2e2ee73b378d364e36 Mon Sep 17 00:00:00 2001 From: bndiaye1 Date: Wed, 21 Jan 2026 13:04:19 +0100 Subject: [PATCH] Add new commands, fix bugs, and improve .env parsing --- commands_test.go | 143 +++++++++++++++++++++++++++++ envfile.go | 99 ++++++++++++++++++-- envfile_test.go | 81 +++++++++++++++++ init_interactive.go | 34 +++---- main.go | 215 ++++++++++++++++++++++++++++++++++++++++++++ spawn.go | 19 +++- 6 files changed, 567 insertions(+), 24 deletions(-) create mode 100644 commands_test.go diff --git a/commands_test.go b/commands_test.go new file mode 100644 index 0000000..6d84112 --- /dev/null +++ b/commands_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" +) + +func TestListCommandExecutes(t *testing.T) { + // Create a temporary project config + dir := t.TempDir() + cfgPath := filepath.Join(dir, ".envmap.yaml") + cfg := `project: test-project +default_env: dev +envs: + dev: + provider: local-dev + path_prefix: /test/dev/ + prod: + provider: aws-ssm + path_prefix: /test/prod/ +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0644); err != nil { + t.Fatal(err) + } + + // Change to the temp directory + oldDir, _ := os.Getwd() + defer os.Chdir(oldDir) + os.Chdir(dir) + + // Test list command runs without error + cmd := newListCmd() + if err := cmd.Execute(); err != nil { + t.Fatalf("list command failed: %v", err) + } +} + +func TestListCommandJSONExecutes(t *testing.T) { + // Create a temporary project config + dir := t.TempDir() + cfgPath := filepath.Join(dir, ".envmap.yaml") + cfg := `project: json-test +default_env: staging +envs: + staging: + provider: local-file + path_prefix: /staging/ +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0644); err != nil { + t.Fatal(err) + } + + // Change to the temp directory + oldDir, _ := os.Getwd() + defer os.Chdir(oldDir) + os.Chdir(dir) + + // Test list command with JSON output + cmd := newListCmd() + cmd.SetArgs([]string{"--json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("list --json command failed: %v", err) + } +} + +func TestListCommandNoConfig(t *testing.T) { + // Create an empty temp directory (no config file) + dir := t.TempDir() + + // Change to the temp directory + oldDir, _ := os.Getwd() + defer os.Chdir(oldDir) + os.Chdir(dir) + + cmd := newListCmd() + err := cmd.Execute() + if err == nil { + t.Error("expected error when no config file exists") + } +} + +func TestDoctorCommandExecutes(t *testing.T) { + // Create a temporary environment with config files + dir := t.TempDir() + + // Create project config + projectCfg := filepath.Join(dir, ".envmap.yaml") + cfg := `project: doctor-test +default_env: dev +envs: + dev: + provider: local-dev + path_prefix: /doctor/dev/ +` + if err := os.WriteFile(projectCfg, []byte(cfg), 0644); err != nil { + t.Fatal(err) + } + + // Change to the temp directory + oldDir, _ := os.Getwd() + defer os.Chdir(oldDir) + os.Chdir(dir) + + cmd := newDoctorCmd() + // Doctor may return error if global config providers don't match, but should execute + _ = cmd.Execute() +} + +func TestCompletionCommandExecutes(t *testing.T) { + shells := []string{"bash", "zsh", "fish", "powershell"} + + for _, shell := range shells { + t.Run(shell, func(t *testing.T) { + // Create a root command with completion subcommand + rootCmd := &cobra.Command{Use: "envmap"} + completionCmd := newCompletionCmd() + rootCmd.AddCommand(completionCmd) + + // Execute the completion subcommand for the specific shell + rootCmd.SetArgs([]string{"completion", shell}) + err := rootCmd.Execute() + if err != nil { + t.Fatalf("completion %s failed: %v", shell, err) + } + }) + } +} + +func TestCompletionCommandInvalidShell(t *testing.T) { + rootCmd := &cobra.Command{Use: "envmap"} + completionCmd := newCompletionCmd() + rootCmd.AddCommand(completionCmd) + + rootCmd.SetArgs([]string{"completion", "invalid-shell"}) + err := rootCmd.Execute() + if err == nil { + t.Error("expected error for invalid shell") + } +} diff --git a/envfile.go b/envfile.go index cef3b4c..c6748b9 100644 --- a/envfile.go +++ b/envfile.go @@ -7,6 +7,13 @@ import ( "strings" ) +// parseDotEnv parses a .env file and returns a map of key-value pairs. +// It handles: +// - Comments (lines starting with #) +// - Inline comments (KEY=value # comment) +// - Quoted values (single and double quotes) +// - Export prefix (export KEY=value) +// - Whitespace around = sign func parseDotEnv(path string) (map[string]string, error) { f, err := os.Open(path) if err != nil { @@ -18,22 +25,100 @@ func parseDotEnv(path string) (map[string]string, error) { scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments if line == "" || strings.HasPrefix(line, "#") { continue } - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { + + // Handle 'export' prefix + if strings.HasPrefix(line, "export ") { + line = strings.TrimPrefix(line, "export ") + line = strings.TrimSpace(line) + } + + // Find the = separator + eqIndex := strings.Index(line, "=") + if eqIndex == -1 { continue } - key := strings.TrimSpace(parts[0]) - val := strings.TrimSpace(parts[1]) - val = strings.Trim(val, `"'`) - if key != "" { - out[key] = val + + key := strings.TrimSpace(line[:eqIndex]) + val := strings.TrimSpace(line[eqIndex+1:]) + + // Skip invalid keys + if key == "" { + continue } + + // Parse the value, handling quotes and inline comments + val = parseEnvValue(val) + + out[key] = val } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("read %s: %w", path, err) } return out, nil } + +// parseEnvValue parses a value from a .env file, handling quotes and inline comments. +func parseEnvValue(val string) string { + if val == "" { + return "" + } + + // Handle double-quoted values + if strings.HasPrefix(val, `"`) { + // Find the closing quote, accounting for escaped quotes + endQuote := findClosingQuote(val[1:], '"') + if endQuote != -1 { + // Extract value between quotes, handling escape sequences + val = val[1 : endQuote+1] + val = strings.ReplaceAll(val, `\"`, `"`) + val = strings.ReplaceAll(val, `\\`, `\`) + val = strings.ReplaceAll(val, `\n`, "\n") + val = strings.ReplaceAll(val, `\t`, "\t") + return val + } + // No closing quote found, remove opening quote + val = val[1:] + } + + // Handle single-quoted values (no escape processing) + if strings.HasPrefix(val, `'`) { + endQuote := strings.Index(val[1:], `'`) + if endQuote != -1 { + return val[1 : endQuote+1] + } + // No closing quote found, remove opening quote + val = val[1:] + } + + // Handle inline comments (only for unquoted values) + if idx := strings.Index(val, " #"); idx != -1 { + val = strings.TrimSpace(val[:idx]) + } + + return val +} + +// findClosingQuote finds the position of the closing quote character, +// skipping escaped quotes (preceded by backslash). +func findClosingQuote(s string, quote rune) int { + escaped := false + for i, r := range s { + if escaped { + escaped = false + continue + } + if r == '\\' { + escaped = true + continue + } + if r == quote { + return i + } + } + return -1 +} diff --git a/envfile_test.go b/envfile_test.go index ffee8e0..7ae8fcb 100644 --- a/envfile_test.go +++ b/envfile_test.go @@ -81,3 +81,84 @@ func TestParseDotEnvNotFound(t *testing.T) { t.Error("expected error for missing file") } } + +func TestParseDotEnvAdvanced(t *testing.T) { + content := `# Advanced parsing test +export EXPORTED_VAR=exported_value +INLINE_COMMENT=value # this is a comment +DOUBLE_QUOTED="hello world" +SINGLE_QUOTED='hello world' +ESCAPED_QUOTE="say \"hello\"" +NEWLINE_ESCAPE="line1\nline2" +TAB_ESCAPE="col1\tcol2" +SPACES_AROUND = value_with_spaces +NO_QUOTES=simple +EMPTY_QUOTED="" +URL=https://example.com/path?query=1 +` + dir := t.TempDir() + path := filepath.Join(dir, ".env") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + got, err := parseDotEnv(path) + if err != nil { + t.Fatalf("parseDotEnv: %v", err) + } + + tests := []struct { + key string + expected string + }{ + {"EXPORTED_VAR", "exported_value"}, + {"INLINE_COMMENT", "value"}, + {"DOUBLE_QUOTED", "hello world"}, + {"SINGLE_QUOTED", "hello world"}, + {"ESCAPED_QUOTE", `say "hello"`}, + {"NEWLINE_ESCAPE", "line1\nline2"}, + {"TAB_ESCAPE", "col1\tcol2"}, + {"SPACES_AROUND", "value_with_spaces"}, + {"NO_QUOTES", "simple"}, + {"EMPTY_QUOTED", ""}, + {"URL", "https://example.com/path?query=1"}, + } + + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + val, ok := got[tt.key] + if !ok { + t.Errorf("key %q not found", tt.key) + return + } + if val != tt.expected { + t.Errorf("got[%q] = %q, want %q", tt.key, val, tt.expected) + } + }) + } +} + +func TestParseEnvValue(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {`"quoted"`, "quoted"}, + {`'single'`, "single"}, + {`plain`, "plain"}, + {`value # comment`, "value"}, + {`"value # not comment"`, "value # not comment"}, + {`""`, ""}, + {`"escaped \"quote\""`, `escaped "quote"`}, + {`"new\nline"`, "new\nline"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := parseEnvValue(tt.input) + if got != tt.expected { + t.Errorf("parseEnvValue(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} diff --git a/init_interactive.go b/init_interactive.go index 03c675a..1d54b2b 100644 --- a/init_interactive.go +++ b/init_interactive.go @@ -72,25 +72,27 @@ func runInteractiveInit(ctx context.Context) error { fmt.Printf("Wrote %s\n", cfgPath) envFile := detectEnvFile() - useEnv := prompt(reader, fmt.Sprintf("Import secrets from detected .env file? (%s) (y/N)", envFile), "N") - if envFile != "" && strings.ToLower(useEnv) == "y" { - entries, err := parseDotEnv(envFile) - if err != nil { - return err - } - if len(entries) == 0 { - fmt.Printf("No keys found in %s\n", envFile) - return nil - } - if err := resetLocalStoreIfNeeded(providerCfg); err != nil { - return err - } - for k, v := range entries { - if err := WriteSecret(ctx, projectCfg, globalCfg, envName, k, v); err != nil { + if envFile != "" { + useEnv := prompt(reader, fmt.Sprintf("Import secrets from detected .env file? (%s) (y/N)", envFile), "N") + if strings.ToLower(useEnv) == "y" { + entries, err := parseDotEnv(envFile) + if err != nil { return err } + if len(entries) == 0 { + fmt.Printf("No keys found in %s\n", envFile) + return nil + } + if err := resetLocalStoreIfNeeded(providerCfg); err != nil { + return err + } + for k, v := range entries { + if err := WriteSecret(ctx, projectCfg, globalCfg, envName, k, v); err != nil { + return err + } + } + fmt.Printf("Imported %d keys from %s\n", len(entries), envFile) } - fmt.Printf("Imported %d keys from %s\n", len(entries), envFile) } return nil } diff --git a/main.go b/main.go index b09bdec..3cd8a10 100644 --- a/main.go +++ b/main.go @@ -51,6 +51,9 @@ func newRootCmd() *cobra.Command { newImportCmd(), newKeygenCmd(), newValidateCmd(), + newListCmd(), + newDoctorCmd(), + newCompletionCmd(), ) return cmd } @@ -500,3 +503,215 @@ func loadProjectConfig() (ProjectConfig, string, error) { } return cfg, path, nil } + +func newListCmd() *cobra.Command { + var jsonOutput bool + c := &cobra.Command{ + Use: "list", + Short: "List all configured environments", + Long: `List all environments configured in the project's .envmap.yaml file. + +Examples: + envmap list + envmap list --json`, + RunE: func(cmd *cobra.Command, args []string) error { + projectCfg, cfgPath, err := loadProjectConfig() + if err != nil { + return err + } + + if jsonOutput { + data := map[string]interface{}{ + "project": projectCfg.Project, + "config_path": cfgPath, + "default_env": projectCfg.DefaultEnv, + "envs": projectCfg.Envs, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(data) + } + + fmt.Printf("Project: %s\n", projectCfg.Project) + fmt.Printf("Config: %s\n", cfgPath) + fmt.Printf("Default: %s\n\n", projectCfg.DefaultEnv) + fmt.Println("Environments:") + + envNames := make([]string, 0, len(projectCfg.Envs)) + for name := range projectCfg.Envs { + envNames = append(envNames, name) + } + sort.Strings(envNames) + + for _, name := range envNames { + env := projectCfg.Envs[name] + marker := " " + if name == projectCfg.DefaultEnv { + marker = "*" + } + fmt.Printf(" %s %s\n", marker, name) + fmt.Printf(" provider: %s\n", env.GetProvider()) + if env.PathPrefix != "" { + fmt.Printf(" path_prefix: %s\n", env.PathPrefix) + } + if env.Prefix != "" { + fmt.Printf(" prefix: %s\n", env.Prefix) + } + } + return nil + }, + } + c.Flags().BoolVar(&jsonOutput, "json", false, "output in JSON format") + return c +} + +func newDoctorCmd() *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Diagnose configuration issues", + Long: `Run diagnostic checks on your envmap configuration. + +Checks performed: + - Project config (.envmap.yaml) exists and is valid + - Global config (~/.envmap/config.yaml) exists and is valid + - All referenced providers are configured + - Encryption key exists and has correct permissions + - Provider connectivity (where possible)`, + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("Running envmap diagnostics...") + fmt.Println() + issues := 0 + + // Check 1: Project config + fmt.Print("Checking project config... ") + projectCfg, cfgPath, err := loadProjectConfig() + if err != nil { + fmt.Printf("FAIL\n Error: %v\n", err) + issues++ + } else { + fmt.Printf("OK (%s)\n", cfgPath) + } + + // Check 2: Global config + fmt.Print("Checking global config... ") + globalCfg, err := LoadGlobalConfig("") + if err != nil { + fmt.Printf("FAIL\n Error: %v\n", err) + issues++ + } else { + fmt.Printf("OK (%s)\n", DefaultGlobalConfigPath()) + } + + // Check 3: Provider references + if projectCfg.Project != "" && len(globalCfg.GetProviders()) > 0 { + fmt.Println("\nChecking provider references...") + providers := globalCfg.GetProviders() + for envName, envCfg := range projectCfg.Envs { + providerName := envCfg.GetProvider() + fmt.Printf(" %s -> %s: ", envName, providerName) + if _, ok := providers[providerName]; ok { + fmt.Println("OK") + } else { + fmt.Println("MISSING") + issues++ + } + } + } + + // Check 4: Encryption key (for local providers) + fmt.Println("\nChecking encryption...") + for name, pCfg := range globalCfg.GetProviders() { + if pCfg.Type == "local-file" || pCfg.Type == "local-store" { + fmt.Printf(" %s key: ", name) + if pCfg.Encryption == nil { + fmt.Println("NOT CONFIGURED") + issues++ + continue + } + if pCfg.Encryption.KeyFile != "" { + info, err := os.Stat(pCfg.Encryption.KeyFile) + if err != nil { + fmt.Printf("MISSING (%s)\n", pCfg.Encryption.KeyFile) + issues++ + } else if info.Mode().Perm()&0o077 != 0 { + fmt.Printf("INSECURE PERMISSIONS (%#o, should be 0600)\n", info.Mode().Perm()) + issues++ + } else { + fmt.Println("OK") + } + } else if pCfg.Encryption.KeyEnv != "" { + if os.Getenv(pCfg.Encryption.KeyEnv) == "" { + fmt.Printf("ENV NOT SET (%s)\n", pCfg.Encryption.KeyEnv) + issues++ + } else { + fmt.Println("OK (from env)") + } + } + } + } + + // Summary + fmt.Println() + if issues == 0 { + fmt.Println("All checks passed! Configuration looks good.") + return nil + } + return fmt.Errorf("found %d issue(s)", issues) + }, + } +} + +func newCompletionCmd() *cobra.Command { + return &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Long: `Generate shell completion scripts for envmap. + +To load completions: + +Bash: + $ source <(envmap completion bash) + # To load completions for each session, execute once: + # Linux: + $ envmap completion bash > /etc/bash_completion.d/envmap + # macOS: + $ envmap completion bash > $(brew --prefix)/etc/bash_completion.d/envmap + +Zsh: + # If shell completion is not already enabled in your environment, + # you will need to enable it. Execute once: + $ echo "autoload -U compinit; compinit" >> ~/.zshrc + # To load completions for each session, execute once: + $ envmap completion zsh > "${fpath[1]}/_envmap" + # Start a new shell for this setup to take effect. + +Fish: + $ envmap completion fish | source + # To load completions for each session, execute once: + $ envmap completion fish > ~/.config/fish/completions/envmap.fish + +PowerShell: + PS> envmap completion powershell | Out-String | Invoke-Expression + # To load completions for every new session, run: + PS> envmap completion powershell > envmap.ps1 + # and source this file from your PowerShell profile. +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletion(os.Stdout) + case "zsh": + return cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + return cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout) + default: + return fmt.Errorf("unknown shell: %s", args[0]) + } + }, + } +} diff --git a/spawn.go b/spawn.go index 0eb4f96..51638d8 100644 --- a/spawn.go +++ b/spawn.go @@ -14,8 +14,25 @@ func SpawnWithEnv(ctx context.Context, command string, args []string, secretEnv cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin - merged := os.Environ() + // Build environment with secrets taking precedence over existing vars + envMap := make(map[string]string) + + // First, load existing environment + for _, e := range os.Environ() { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + envMap[parts[0]] = parts[1] + } + } + + // Then override with secrets (secrets take precedence) for k, v := range secretEnv { + envMap[k] = v + } + + // Convert back to slice + merged := make([]string, 0, len(envMap)) + for k, v := range envMap { merged = append(merged, fmt.Sprintf("%s=%s", k, v)) } cmd.Env = merged