Skip to content
Merged
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
20 changes: 16 additions & 4 deletions pkg/envparser/envparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,7 @@ func handleEnvVarValue(val string) string {
return val
}

// LoadEnvVars returns map of the key-value pair from the provided .env filepath
func LoadEnvVars(filePath string) (map[string]any, error) {
func loadEnvVarsFromFile(filePath string, resolveOsVar bool) (map[string]any, error) {
hulakEnvironmentVariable := make(map[string]any)
file, err := os.Open(filePath)
if err != nil {
Expand Down Expand Up @@ -138,10 +137,12 @@ func LoadEnvVars(filePath string) (map[string]any, error) {
}
key := strings.TrimSpace(secret[0])
val := strings.TrimSpace(secret[1])
val = handleEnvVarValue(val)

val, wasTrimmed := trimQuotes(val)
if resolveOsVar {
val = handleEnvVarValue(val)
}

val, wasTrimmed := trimQuotes(val)
// Infer value type and assign to the map
hulakEnvironmentVariable[key] = inferType(val, wasTrimmed)
}
Expand All @@ -153,6 +154,17 @@ func LoadEnvVars(filePath string) (map[string]any, error) {
return hulakEnvironmentVariable, nil
}

// LoadEnvVars returns map of the key-value pair from the provided .env filepath
func LoadEnvVars(filePath string) (map[string]any, error) {
return loadEnvVarsFromFile(filePath, true)
}

// LoadEnvVarsRaw returns raw key-value pairs without resolving $VAR references.
// Used by migration to preserve literal $VAR strings in the vault store.
func LoadEnvVarsRaw(filePath string) (map[string]any, error) {
return loadEnvVarsFromFile(filePath, false)
}

// Helper function to infer type of a value
func inferType(val string, wasTrimmed bool) any {
if !wasTrimmed {
Expand Down
76 changes: 76 additions & 0 deletions pkg/envparser/envparser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,82 @@ KEY3='value3'
}
}

func TestLoadEnvVarsRaw(t *testing.T) {
t.Setenv("HULAK_TEST_RAW_TOKEN", "resolved_value")

content := `
# comment line
TOKEN=$HULAK_TEST_RAW_TOKEN
MISSING=$HULAK_TEST_DOES_NOT_EXIST
PLAIN=hello
PORT=8080
RATE=3.14
DEBUG=true
QUOTED="some string"
`
filePath, err := createTempEnvFile(content)
if err != nil {
t.Fatalf("Failed to create temp env file: %v", err)
}
defer os.Remove(filePath)

raw, err := LoadEnvVarsRaw(filePath)
if err != nil {
t.Fatalf("LoadEnvVarsRaw error: %v", err)
}

t.Run("preserves dollar var as literal", func(t *testing.T) {
if raw["TOKEN"] != "$HULAK_TEST_RAW_TOKEN" {
t.Errorf("TOKEN = %v, want literal $HULAK_TEST_RAW_TOKEN", raw["TOKEN"])
}
})

t.Run("preserves missing dollar var as literal", func(t *testing.T) {
if raw["MISSING"] != "$HULAK_TEST_DOES_NOT_EXIST" {
t.Errorf("MISSING = %v, want literal $HULAK_TEST_DOES_NOT_EXIST", raw["MISSING"])
}
})

t.Run("plain string unchanged", func(t *testing.T) {
if raw["PLAIN"] != "hello" {
t.Errorf("PLAIN = %v, want hello", raw["PLAIN"])
}
})

t.Run("type inference still works", func(t *testing.T) {
if raw["PORT"] != 8080 {
t.Errorf("PORT = %v (%T), want int 8080", raw["PORT"], raw["PORT"])
}
if raw["RATE"] != 3.14 {
t.Errorf("RATE = %v (%T), want float 3.14", raw["RATE"], raw["RATE"])
}
if raw["DEBUG"] != true {
t.Errorf("DEBUG = %v (%T), want bool true", raw["DEBUG"], raw["DEBUG"])
}
})

t.Run("quoted string stays string", func(t *testing.T) {
if raw["QUOTED"] != "some string" {
t.Errorf("QUOTED = %v, want 'some string'", raw["QUOTED"])
}
})

// Regression: LoadEnvVars still resolves $VAR
t.Run("LoadEnvVars still resolves dollar var", func(t *testing.T) {
resolved, err := LoadEnvVars(filePath)
if err != nil {
t.Fatalf("LoadEnvVars error: %v", err)
}
if resolved["TOKEN"] != "resolved_value" {
t.Errorf("TOKEN = %v, want resolved_value", resolved["TOKEN"])
}
// $MISSING resolves to empty string (var not set)
if resolved["MISSING"] != "" {
t.Errorf("MISSING = %v, want empty string", resolved["MISSING"])
}
})
}

func setupVaultProject(t *testing.T, store *vault.Store) {
t.Helper()

Expand Down
180 changes: 180 additions & 0 deletions pkg/userFlags/env_migrate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package userflags

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/xaaha/hulak/pkg/envparser"
"github.com/xaaha/hulak/pkg/utils"
"github.com/xaaha/hulak/pkg/vault"
)

// runEnvMigrate converts env/*.env files into the encrypted vault store.
// Existing store values win on conflicts, making re-runs safe.
// The env/ directory is NOT deleted — users do that manually.
func runEnvMigrate() error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("could not determine current directory: %w", err)
}

envDir := filepath.Join(cwd, utils.EnvironmentFolder)
if err := requireDirectory(envDir); err != nil {
return err
}

// Snapshot before EnsureKeypair so we only show the backup
// warning when a brand-new identity is generated.
wasFresh := !vault.IdentityExists()

ageKey, store, err := bootstrapVault(cwd)
if err != nil {
return err
}

if err := migrateEnvFiles(envDir, store); err != nil {
return err
}

if err := vault.WriteStoreToRecipients(store); err != nil {
return err
}

return printMigrateSummary(wasFresh, ageKey)
}

// requireDirectory checks that path exists and is a directory.
func requireDirectory(path string) error {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("no %s/ directory found — nothing to migrate", filepath.Base(path))
}
return fmt.Errorf("cannot access %s/: %w", filepath.Base(path), err)
}
if !info.IsDir() {
return fmt.Errorf("expected %s/ to be a directory, got a file", filepath.Base(path))
}
return nil
}

// bootstrapVault ensures .hulak/, identity, and recipients exist,
// then returns the keypair and the current store (empty if first run).
func bootstrapVault(projectRoot string) (vault.AgeKey, *vault.Store, error) {
hulakDir := filepath.Join(projectRoot, utils.HiddenProjectName)
if err := os.MkdirAll(hulakDir, utils.DirPer); err != nil {
return vault.AgeKey{}, nil, fmt.Errorf("could not create %s/: %w", utils.HiddenProjectName, err)
}

ageKey, err := vault.EnsureKeypair()
if err != nil {
return vault.AgeKey{}, nil, err
}

if err := ensureRecipientsFile(ageKey); err != nil {
return vault.AgeKey{}, nil, err
}

store, err := vault.ReadStore(ageKey.Identity)
if err != nil {
return vault.AgeKey{}, nil, err
}

return ageKey, store, nil
}

// migrateEnvFiles reads each *.env file in envDir and merges its
// key-value pairs into store. Existing store values take precedence
// so re-running migration never overwrites post-migration edits.
// Non-.env files are skipped with a warning.
func migrateEnvFiles(envDir string, store *vault.Store) error {
entries, err := os.ReadDir(envDir)
if err != nil {
return fmt.Errorf("failed to read %s/: %w", filepath.Base(envDir), err)
}

for _, entry := range entries {
if entry.IsDir() {
continue
}

name := entry.Name()
if !strings.HasSuffix(name, utils.DefaultEnvFileSuffix) {
utils.PrintWarningStderr(fmt.Sprintf("Skipped: %s (not a .env file)", name))
continue
}

envName := strings.TrimSuffix(name, utils.DefaultEnvFileSuffix)
if err := utils.ValidateEnvName(envName); err != nil {
return fmt.Errorf("invalid env file %q: %w", name, err)
}

filePath := filepath.Join(envDir, name)
if err := mergeEnvFileIntoStore(filePath, envName, store); err != nil {
return err
}
}

return nil
}

// mergeEnvFileIntoStore parses a single .env file with raw values
// (preserving $VAR literals) and adds new keys to the store section.
// Keys that already exist in the store are skipped.
func mergeEnvFileIntoStore(filePath, envName string, store *vault.Store) error {
parsed, err := envparser.LoadEnvVarsRaw(filePath)
if err != nil {
return fmt.Errorf("failed to parse %s: %w", filepath.Base(filePath), err)
}

store.EnsureSection(envName)
existing := store.GetEnv(envName)

newKeys, skipped := 0, 0
for key, val := range parsed {
if _, exists := existing[key]; exists {
skipped++
continue
}
store.SetKey(envName, key, val)
newKeys++
}

fileName := filepath.Base(filePath)
if skipped > 0 {
utils.PrintSuccessStderr(fmt.Sprintf(
"Migrated %s → store.age[%s] (%d new, %d skipped)", fileName, envName, newKeys, skipped,
))
} else {
utils.PrintSuccessStderr(fmt.Sprintf(
"Migrated %s → store.age[%s] (%d keys)", fileName, envName, newKeys,
))
}

return nil
}

// printMigrateSummary shows identity details on first-time setup
// and reminds the user that env/ is untouched.
func printMigrateSummary(wasFresh bool, ageKey vault.AgeKey) error {
if wasFresh {
identityPath, err := vault.IdentityPath()
if err != nil {
return fmt.Errorf("could not resolve identity path: %w", err)
}
fmt.Fprintf(os.Stderr, "\n Identity file: %s\n", identityPath)
fmt.Fprintf(os.Stderr, " Public key: %s\n", ageKey.Recipient)
utils.PrintWarningStderr(
"Back up the identity file — losing it means losing access to the vault.",
)
}

fmt.Fprintln(os.Stderr)
utils.PrintInfoStderr(
"env/ is untouched. The encrypted store now takes priority.\n" +
"Delete it manually when ready: rm -rf env/",
)
return nil
}
Loading
Loading