package cmd import ( "bufio" "errors" "fmt" "os" "path/filepath" "strings" "envault/vault" "github.com/spf13/cobra" ) var ( flagProject string flagVaultPath string ) var rootCmd = &cobra.Command{ Use: "ev", Short: "Local encrypted secret manager", Long: `ev stores secrets encrypted outside your project directory, keeping them away from AI coding agents, git repos, and logs.`, } // SetVersion injects the build-time version into the root command. func SetVersion(v string) { rootCmd.Version = v } func Execute() { if err := rootCmd.Execute(); err != nil { os.Exit(1) } } func init() { rootCmd.PersistentFlags().StringVarP(&flagProject, "project", "p", "", "project name (overrides .envault file)") rootCmd.PersistentFlags().StringVar(&flagVaultPath, "vault", "", "vault file path (default: ~/.envault/vault.json)") } // resolveProject returns the active project name. // Priority: --project flag > .envault (searched upward) > current directory name func resolveProject() string { if flagProject != "" { return flagProject } cwd, err := os.Getwd() if err != nil { return "default" } if name, err := findProjectFile(cwd); err == nil && name != "" { return name } // Warn if a .envault file exists locally but couldn't be parsed if _, err := os.Stat(filepath.Join(cwd, ".envault")); err == nil { fmt.Fprintln(os.Stderr, "Warning: .envault file found but could not be parsed — falling back to directory name") } return filepath.Base(cwd) } // findProjectFile walks up the directory tree from start looking for a .envault file. // Stops at a git root (.git directory) or filesystem root to avoid picking up // unrelated .envault files from parent repositories or home directories. func findProjectFile(start string) (string, error) { dir := start for { name, err := parseProjectFile(filepath.Join(dir, ".envault")) if err == nil { return name, nil } // Stop at git repository root — don't leak into parent repos if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { break } parent := filepath.Dir(dir) if parent == dir { break // filesystem root reached } dir = parent } return "", fmt.Errorf(".envault not found") } // parseProjectFile reads the project name from a single .envault file. func parseProjectFile(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, "=", 2) if len(parts) == 2 && strings.TrimSpace(parts[0]) == "project" { return strings.TrimSpace(parts[1]), nil } return line, nil } return "", fmt.Errorf("empty .envault file") } // resolveVaultPath returns the active vault path. func resolveVaultPath() (string, error) { if flagVaultPath != "" { return flagVaultPath, nil } return vault.DefaultVaultPath() } // trySession returns cached vars from an active session, or nil if none exists. // Commands that only read secrets should call this first before prompting for a password. func trySession() map[string]string { vars, _ := vault.LoadSession(resolveProject()) return vars } // openVaultKeychain opens the vault using the password stored in macOS Keychain. // Falls back to a password prompt if no Keychain entry exists. func openVaultKeychain() (*vault.Vault, string, string, error) { path, err := resolveVaultPath() if err != nil { return nil, "", "", err } password, err := vault.KeychainGet() if err != nil { fmt.Fprintln(os.Stderr, "No password in Keychain — prompting.") return openVault() } v, err := vault.Open(path, password) if err != nil { return nil, "", "", err } return v, path, password, nil } // openVault prompts for the master password and opens (or creates) the vault. // If the vault is new, it asks to set a password with confirmation. func openVault() (*vault.Vault, string, string, error) { path, err := resolveVaultPath() if err != nil { return nil, "", "", err } var password string if !vault.Exists(path) { fmt.Fprintln(os.Stderr, "No vault found – creating a new one.") p1, err := vault.PromptPassword("Set master password: ") if err != nil { return nil, "", "", err } if p1 == "" { return nil, "", "", errors.New("master password must not be empty") } p2, err := vault.PromptPassword("Confirm master password: ") if err != nil { return nil, "", "", err } if p1 != p2 { return nil, "", "", errors.New("passwords do not match") } password = p1 } else { password, err = vault.PromptPassword("Master password: ") if err != nil { return nil, "", "", err } } v, err := vault.Open(path, password) if err != nil { return nil, "", "", err } return v, path, password, nil }