Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions commands_test.go
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +30 to +32
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error returned from os.Getwd() is being silently ignored. If getting the current directory fails, the deferred os.Chdir(oldDir) will use an empty string, which may cause unexpected behavior. Consider handling this error or failing the test if it occurs.

Copilot uses AI. Check for mistakes.

// 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)
Comment on lines +57 to +59
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error returned from os.Getwd() is being silently ignored. If getting the current directory fails, the deferred os.Chdir(oldDir) will use an empty string, which may cause unexpected behavior. Consider handling this error or failing the test if it occurs.

Copilot uses AI. Check for mistakes.

// 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)
Comment on lines +75 to +77
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error returned from os.Getwd() is being silently ignored. If getting the current directory fails, the deferred os.Chdir(oldDir) will use an empty string, which may cause unexpected behavior. Consider handling this error or failing the test if it occurs.

Copilot uses AI. Check for mistakes.

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)
Comment on lines +104 to +106
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error returned from os.Getwd() is being silently ignored. If getting the current directory fails, the deferred os.Chdir(oldDir) will use an empty string, which may cause unexpected behavior. Consider handling this error or failing the test if it occurs.

Copilot uses AI. Check for mistakes.

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")
}
}
99 changes: 92 additions & 7 deletions envfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
Comment on lines +79 to +81
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The order of escape sequence replacements is incorrect and will produce wrong results. When you replace \\ with \ before processing other escape sequences like \n and \t, the literal \\n will first become \n (a backslash followed by 'n'), which will then incorrectly be replaced with a newline character.

For example, if the input is "\\n" (representing a literal backslash followed by 'n'), the current code will:

  1. Replace \\ with \"\n"
  2. Replace \n with newline → produces a newline instead of the intended \n

The correct order should be to process specific escape sequences first (\n, \t, \") and then handle \\ last.

Suggested change
val = strings.ReplaceAll(val, `\\`, `\`)
val = strings.ReplaceAll(val, `\n`, "\n")
val = strings.ReplaceAll(val, `\t`, "\t")
val = strings.ReplaceAll(val, `\n`, "\n")
val = strings.ReplaceAll(val, `\t`, "\t")
val = strings.ReplaceAll(val, `\\`, `\`)

Copilot uses AI. Check for mistakes.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ai comment is correct - the order was on purpose here.

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
}
81 changes: 81 additions & 0 deletions envfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
Comment on lines +85 to +139
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for escaped backslashes in double-quoted strings. The test should include a case like ESCAPED_BACKSLASH="\\n" which should produce the literal string \n (backslash followed by n), not a newline character. This would catch the incorrect ordering of escape sequence replacements.

Copilot uses AI. Check for mistakes.

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)
}
})
}
}
34 changes: 18 additions & 16 deletions init_interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading