diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c61ed60 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Database +DATABASE_URL= +JWT_SECRET= +PORT=3000 diff --git a/.envsync.yaml b/.envsync.yaml new file mode 100644 index 0000000..7b8671f --- /dev/null +++ b/.envsync.yaml @@ -0,0 +1,5 @@ +provider: keepass +schema_file: .env.example +env_file: .env +keepass_database: test-secrets.kdbx +keepass_group: dotenv diff --git a/.gitignore b/.gitignore index 470b298..43de32b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ Thumbs.db *.swp .vscode/ .idea/ +*.kdbx diff --git a/README.md b/README.md index 7f5bb73..54a5e80 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,19 @@ `dotenv-sync` is a cross-platform Go CLI for keeping a local `.env` file aligned with a schema in `.env.example` while resolving provider-managed values through -Bitwarden's `rbw` CLI. +a secret provider. Supported providers are **Bitwarden** (via the `rbw` CLI) and +**KeePass** (via `keepassxc-cli`). The default provider is Bitwarden. The product name stays **dotenv-sync** and the default binary name is **`ds`**. ## Features -- `ds sync` writes `.env` from `.env.example` and `rbw` +- `ds sync` writes `.env` from `.env.example` and your secret provider - `ds push` uploads the current `.env` into a repo-scoped Bitwarden item - `ds diff` previews drift without writing files - `ds validate` reports malformed files, drift, duplicates, and missing secrets -- `ds doctor` checks config and `rbw` readiness -- `ds init` bootstraps `.env.example` from `.env` +- `ds doctor` checks config and provider readiness +- `ds init` bootstraps `.env.example` from `.env`, with first-run provider setup - `ds missing` lists unresolved provider-backed keys - `ds reverse` adds missing schema placeholders back into `.env.example` - `ds --version` and `ds version` report build and release metadata @@ -137,7 +138,10 @@ install -Dm755 ./bin/ds ~/.local/bin/ds ## Configuration -`.envsync.yaml` is optional: +`.envsync.yaml` is optional. Running `ds init` or `ds scaffold` with no config +present will walk you through first-run setup and write it for you. + +### Bitwarden ```yaml provider: bitwarden @@ -150,6 +154,25 @@ mapping: JWT_SECRET: auth_jwt ``` +### KeePass + +```yaml +provider: keepass +schema_file: .env.example +env_file: .env +keepass_database: /path/to/secrets.kdbx +keepass_group: dotenv +``` + +`keepass_database` is the path to your `.kdbx` file (absolute or relative to the +project root). `keepass_group` is the group inside the vault that holds env var +entries — each entry's title is the key name and its password field is the +secret value. Defaults to `dotenv` if omitted. + +`.kdbx` files are automatically ignored by the bundled `.gitignore` entry. + +### General + Blank values in `.env.example` are treated as provider-managed secrets. Literal values are treated as safe defaults and copied into `.env`. @@ -192,7 +215,7 @@ ds sync --dry-run ``` - Reads `.env.example` as the schema contract -- Resolves blank entries through `rbw` using a repo-scoped item by default +- Resolves blank entries through the configured provider - Preserves comment order and line endings when rewriting `.env` - Produces `WRITTEN`, `UNCHANGED`, and `MISSING` output vocabulary for sync runs - On successful writes, prints the changed keys before the final summary without @@ -244,8 +267,8 @@ secrets are found. ds doctor ``` -Checks `.envsync.yaml` readability and `rbw` readiness without printing any -secret values. +Checks `.envsync.yaml` readability and provider readiness without printing any +secret values. Works for both Bitwarden and KeePass providers. ### `ds init` @@ -257,10 +280,35 @@ ds init --dry-run Creates `.env.example` from `.env`, blanking secret-like values while copying safe defaults. +If no `.envsync.yaml` exists and stdin is a terminal, `ds init` runs first-run +setup — asking which provider to use and writing the config file before +continuing. For KeePass, it verifies the database path and group exist before +writing anything. + If `.env` is malformed or contains duplicate keys, `ds init` returns a validation error with actionable diagnostics instead of silently generating a schema. +### `ds scaffold` + +```bash +ds scaffold +ds scaffold --dry-run +``` + +Seeds a KeePass vault with blank entries for every provider-managed key in +`.env.example`. Existing entries are skipped — never overwritten. Only +supported when `provider: keepass`. + +This is a **dev lead tool** for the recommended KeePass team workflow: + +1. Design `.env.example` with blank values for all secrets +2. Run `ds scaffold` — creates stub entries in the vault +3. Open KeePassXC, fill in the real secret values +4. Run `ds sync` to verify `.env` populates correctly +5. Share the `.kdbx` file with team members via a secure channel +6. Team members run `ds sync` and are ready to go + ### `ds missing` ```bash diff --git a/go.mod b/go.mod index af1e084..8fcb2e4 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,15 @@ module dotenv-sync -go 1.22 +go 1.25.0 require ( github.com/spf13/cobra v1.8.1 + golang.org/x/term v0.43.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.44.0 // indirect ) diff --git a/go.sum b/go.sum index a01295b..36a2ffe 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,10 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index fbf110b..2db6c7b 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -24,7 +24,7 @@ func newDoctorCommand(s streams, opts *rootOptions) *cobra.Command { if status.Code != "" { return report.NewAppError(status.Code, report.ExitOperational, status.Problem, status.Impact, status.Action, nil) } - fmt.Fprintln(s.stdout, report.SummaryLine(report.StatusChecked, "provider", report.Summary{}, "rbw ready")) + fmt.Fprintln(s.stdout, report.SummaryLine(report.StatusChecked, "provider", report.Summary{}, status.Provider+" ready")) return nil }, } diff --git a/internal/cli/init.go b/internal/cli/init.go index 32d5af8..1845719 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -13,12 +13,17 @@ func newInitCommand(s streams, opts *rootOptions) *cobra.Command { var dryRun bool cmd := &cobra.Command{ Use: "init", - Short: "Generate .env.example from .env", + Short: "Generate .env.example from .env, with first-run provider setup", RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := loadConfig(opts) + setupRan, cfg, _, err := ensureConfig(s, opts) if err != nil { return err } + if setupRan { + fmt.Fprintln(s.stdout, "Run 'ds sync' to populate your .env file.") + return nil + } + plan, target, err := syncpkg.PlanInit(cfg) for _, change := range plan.Changes { fmt.Fprintln(s.stdout, report.ChangeLine(change.ChangeType, change.Key, change.After)) @@ -44,4 +49,4 @@ func newInitCommand(s streams, opts *rootOptions) *cobra.Command { } cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview without writing") return cmd -} +} \ No newline at end of file diff --git a/internal/cli/push.go b/internal/cli/push.go index 93af4ce..fe95b7a 100644 --- a/internal/cli/push.go +++ b/internal/cli/push.go @@ -20,6 +20,12 @@ func newPushCommand(s streams, opts *rootOptions) *cobra.Command { if err != nil { return err } + if cfg.Provider == "keepass" { + return report.NewAppError("E008", report.ExitOperational, + "push is not supported for the keepass provider", + "ds push writes secrets to Bitwarden — it cannot write to a KeePass database", + "use KeePassXC to edit secrets directly, then run ds sync", nil) + } prov := pushProviderFor(cfg) plan, target, err := syncpkg.PlanPush(cmd.Context(), cfg, prov) var appErr *report.AppError @@ -57,4 +63,4 @@ func newPushCommand(s streams, opts *rootOptions) *cobra.Command { } cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview without writing to Bitwarden") return cmd -} +} \ No newline at end of file diff --git a/internal/cli/root.go b/internal/cli/root.go index 40b7c43..7d925df 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -9,16 +9,24 @@ import ( "dotenv-sync/internal/config" "dotenv-sync/internal/provider" "dotenv-sync/internal/provider/bitwarden" + "dotenv-sync/internal/provider/keepass" "dotenv-sync/internal/report" "dotenv-sync/pkg/dotenvsync" "github.com/spf13/cobra" ) var providerFactory = func(cfg config.Config) provider.Provider { - return bitwarden.NewAdapter(cfg) + switch cfg.Provider { + case "keepass": + return keepass.NewAdapter(cfg) + default: + return bitwarden.NewAdapter(cfg) + } } var pushProviderFactory = func(cfg config.Config) provider.PushProvider { + // KeePass does not yet implement PushProvider (ds push is Bitwarden-only for now). + // All push operations fall through to Bitwarden regardless of config. return bitwarden.NewAdapter(cfg) } @@ -74,6 +82,7 @@ func NewRootCommand(s streams) *cobra.Command { cmd.AddCommand(newInitCommand(s, opts)) cmd.AddCommand(newMissingCommand(s, opts)) cmd.AddCommand(newReverseCommand(s, opts)) + cmd.AddCommand(newScaffoldCommand(s, opts)) return cmd } diff --git a/internal/cli/scaffold.go b/internal/cli/scaffold.go new file mode 100644 index 0000000..e878f64 --- /dev/null +++ b/internal/cli/scaffold.go @@ -0,0 +1,104 @@ +package cli + +import ( + "errors" + "fmt" + + "dotenv-sync/internal/envfile" + "dotenv-sync/internal/provider/keepass" + "dotenv-sync/internal/report" + "github.com/spf13/cobra" +) + +func newScaffoldCommand(s streams, opts *rootOptions) *cobra.Command { + var dryRun bool + cmd := &cobra.Command{ + Use: "scaffold", + Short: "Seed KeePass with blank entries for all provider-managed schema keys", + Long: `scaffold reads .env.example and creates a blank KeePass entry for every +provider-managed key (blank value in .env.example) that does not already exist +in the configured group. Existing entries are skipped, never overwritten. + +After scaffold, open KeePassXC, fill in the real secret values, then run ds sync. + +Only supported when provider is keepass.`, + RunE: func(cmd *cobra.Command, args []string) error { + _, cfg, setupPassword, err := ensureConfig(s, opts) + if err != nil { + return err + } + + // scaffold is KeePass-only. + if cfg.Provider != "keepass" { + return report.NewAppError("E007", report.ExitOperational, + "scaffold requires provider: keepass", + "scaffold cannot create entries in Bitwarden via this command", + "set provider: keepass in .envsync.yaml and retry", nil) + } + + // Parse the schema to find provider-managed keys (blank values). + schema, err := envfile.ParseFile(cfg.SchemaFile, envfile.KindSchema) + if err != nil { + return report.NewAppError("E004", report.ExitOperational, + "schema file missing", + "scaffold cannot determine which keys to create", + "create .env.example or run 'ds init'", err) + } + + // Collect keys that need entries — blank value = provider-managed. + var keys []string + for _, line := range schema.Lines { + if line.LineType == envfile.LineAssignment && line.ManagedByProvider { + keys = append(keys, line.Key) + } + } + + if len(keys) == 0 { + fmt.Fprintln(s.stdout, "No provider-managed keys found in schema — nothing to scaffold.") + return nil + } + + if dryRun { + fmt.Fprintf(s.stdout, "Would scaffold %d key(s) into %s (group: %s):\n", len(keys), cfg.KeePassDatabase, cfg.KeePassGroup) + for _, key := range keys { + fmt.Fprintln(s.stdout, report.ChangeLine("add", key, "[DRY-RUN]")) + } + return nil + } + + // Build the adapter. If setup just ran, reuse the password already + // collected so the user is not prompted a second time. + adapter := keepass.NewAdapter(cfg) + if setupPassword != "" { + adapter.SetPassword(setupPassword) + } else if err := adapter.EnsurePassword(cmd.Context()); err != nil { + return report.NewAppError("E003", report.ExitOperational, + "KeePass master password could not be read", + "scaffold cannot create entries without vault access", + "check your master password and retry", err) + } + + created, skipped, failed := 0, 0, 0 + for _, key := range keys { + err := adapter.CreateEntry(cmd.Context(), key) + if err == nil { + fmt.Fprintln(s.stdout, report.ChangeLine("add", key, "[CREATED]")) + created++ + } else if errors.Is(err, keepass.ErrEntryExists) { + fmt.Fprintln(s.stdout, report.ChangeLine("skip", key, "[EXISTS]")) + skipped++ + } else { + fmt.Fprintln(s.stdout, report.ChangeLine("error", key, "[FAILED]")) + failed++ + } + } + + summary := report.Summary{Added: created, Unchanged: skipped, Error: failed} + fmt.Fprintln(s.stdout, report.SummaryLine(report.StatusWritten, cfg.KeePassDatabase, summary, "")) + fmt.Fprintln(s.stdout, "Open KeePassXC, fill in the secret values, then run 'ds sync'.") + return nil + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview without creating entries") + return cmd +} \ No newline at end of file diff --git a/internal/cli/setup.go b/internal/cli/setup.go new file mode 100644 index 0000000..7d90b37 --- /dev/null +++ b/internal/cli/setup.go @@ -0,0 +1,130 @@ +package cli + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "dotenv-sync/internal/config" + "dotenv-sync/internal/provider/keepass" + "dotenv-sync/internal/report" + "golang.org/x/term" +) + +// ensureConfig checks whether .envsync.yaml exists. If it doesn't and stdin +// is a TTY, it runs the interactive first-run setup and writes the config. +// Returns (setupRan, cfg, password, err). +// password is non-empty only when setup ran for a KeePass provider — the +// caller can use it to pre-seed the adapter and avoid a second prompt. +func ensureConfig(s streams, opts *rootOptions) (setupRan bool, cfg config.Config, password string, err error) { + cfg, err = loadConfig(opts) + if err != nil { + return false, config.Config{}, "", err + } + + _, statErr := os.Stat(cfg.ConfigFile) + isInteractive := term.IsTerminal(int(os.Stdin.Fd())) + if !os.IsNotExist(statErr) || !isInteractive { + // Config exists, or we're not interactive — nothing to do. + return false, cfg, "", nil + } + + password, err = runFirstTimeSetup(s, cfg) + if err != nil { + // Setup cancelled or failed — message already printed. + return false, config.Config{}, "", err + } + + // Reload config so callers get the freshly written values. + cfg, err = loadConfig(opts) + if err != nil { + return false, config.Config{}, "", err + } + return true, cfg, password, nil +} + +// runFirstTimeSetup prompts the user for provider preferences and writes +// .envsync.yaml. Nothing is written to disk until all inputs are validated. +// Returns the KeePass master password if one was collected, so callers can +// reuse it without prompting again. +func runFirstTimeSetup(s streams, cfg config.Config) (password string, err error) { + reader := bufio.NewReader(os.Stdin) + + fmt.Fprintln(s.stdout, "No .envsync.yaml found. Let's set up dotenv-sync.") + fmt.Fprintln(s.stdout, "") + + // Ask which provider to use. Default is bitwarden. + fmt.Fprint(s.stdout, "Provider? [bitwarden/keepass] (default: bitwarden): ") + providerInput, _ := reader.ReadString('\n') + providerInput = strings.TrimSpace(strings.ToLower(providerInput)) + if providerInput == "" { + providerInput = "bitwarden" + } + + if providerInput != "bitwarden" && providerInput != "keepass" { + fmt.Fprintf(s.stdout, "Setup failed: unknown provider %q — must be bitwarden or keepass. Please run again.\n", providerInput) + return "", fmt.Errorf("setup cancelled") + } + + var dbPath, groupName string + + if providerInput == "keepass" { + fmt.Fprint(s.stdout, "Path to KeePass database (.kdbx): ") + dbPath, _ = reader.ReadString('\n') + dbPath = strings.TrimSpace(dbPath) + if dbPath == "" { + fmt.Fprintln(s.stdout, "Setup failed: KeePass database path cannot be empty. Please run again.") + return "", fmt.Errorf("setup cancelled") + } + + if _, err := os.Stat(dbPath); err != nil { + fmt.Fprintf(s.stdout, "Setup failed: database file not found at %q. Please run again.\n", dbPath) + return "", fmt.Errorf("setup cancelled") + } + + fmt.Fprint(s.stdout, "Group name for env vars (default: dotenv): ") + groupName, _ = reader.ReadString('\n') + groupName = strings.TrimSpace(groupName) + if groupName == "" { + groupName = "dotenv" + } + + // Verify the group exists in the vault before writing anything. + client := keepass.NewKPXCClient() + fmt.Fprintf(os.Stderr, "Enter KeePass master password for %s: ", dbPath) + pw, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + fmt.Fprintln(s.stdout, "Setup failed: could not read master password. Please run again.") + return "", fmt.Errorf("setup cancelled") + } + client.Password = strings.TrimSpace(string(pw)) + password = client.Password + + if _, err := client.ListGroup(context.Background(), dbPath, groupName); err != nil { + fmt.Fprintf(s.stdout, "Setup failed: group %q not found in %s. Please run again.\n", groupName, dbPath) + return "", fmt.Errorf("setup cancelled") + } + } + + // All inputs valid — write the config. + var sb strings.Builder + sb.WriteString("provider: " + providerInput + "\n") + sb.WriteString("schema_file: .env.example\n") + sb.WriteString("env_file: .env\n") + if providerInput == "keepass" { + sb.WriteString("keepass_database: " + dbPath + "\n") + sb.WriteString("keepass_group: " + groupName + "\n") + } + + if err := os.WriteFile(cfg.ConfigFile, []byte(sb.String()), 0o600); err != nil { + return "", report.NewAppError("E006", report.ExitOperational, "config file could not be written", "setup could not create .envsync.yaml", "check file permissions and retry", err) + } + + fmt.Fprintln(s.stdout, "") + fmt.Fprintln(s.stdout, "WRITTEN "+cfg.ConfigFile) + fmt.Fprintln(s.stdout, "") + return password, nil +} \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 6142ead..bf8f38d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,10 @@ type Config struct { Mapping map[string]string `yaml:"mapping"` ConfigFile string `yaml:"-"` BaseDir string `yaml:"-"` + + // KeePass-specific fields. Ignored when provider is "bitwarden". + KeePassDatabase string `yaml:"keepass_database"` // path to the .kdbx file + KeePassGroup string `yaml:"keepass_group"` // group inside the vault that holds env vars } type LoadOptions struct { @@ -85,6 +89,15 @@ func Load(baseDir string, opts LoadOptions) (Config, error) { if cfg.Mapping == nil { cfg.Mapping = map[string]string{} } + // Resolve the KeePass database path relative to the base directory, + // the same way schema_file and env_file are resolved. + if cfg.KeePassDatabase != "" { + cfg.KeePassDatabase = resolvePath(baseDir, cfg.KeePassDatabase) + } + // Default the KeePass group to "dotenv" if not specified. + if cfg.KeePassGroup == "" { + cfg.KeePassGroup = "dotenv" + } if cfg.StorageMode != StorageModeFields && cfg.StorageMode != StorageModeNoteJSON { return Config{}, fmt.Errorf("storage_mode must be %q or %q", StorageModeFields, StorageModeNoteJSON) } @@ -149,4 +162,4 @@ func repoRoot(start string) string { } dir = parent } -} +} \ No newline at end of file diff --git a/internal/provider/keepass/adapter.go b/internal/provider/keepass/adapter.go new file mode 100644 index 0000000..b124393 --- /dev/null +++ b/internal/provider/keepass/adapter.go @@ -0,0 +1,207 @@ +package keepass + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "sync" + + "dotenv-sync/internal/config" + "dotenv-sync/internal/provider" + + "golang.org/x/term" +) + +// Adapter implements provider.Provider using KeePassXC as the secret backend. +// It satisfies the same interface as the Bitwarden adapter, so the rest of the +// tool (sync, diff, validate, doctor) works without any changes. +type Adapter struct { + client *KPXCClient + cfg config.Config + + // mu guards the resolution cache so concurrent ResolveMany calls are safe. + mu sync.Mutex + cache map[string]provider.Resolution +} + +// NewAdapter creates a KeePass adapter from the loaded config. +// The master password is NOT prompted here — that happens lazily on the first +// call that actually needs the database, so `ds doctor` can still check +// whether the binary is installed without asking for a password. +func NewAdapter(cfg config.Config) *Adapter { + return &Adapter{ + client: NewKPXCClient(), + cfg: cfg, + cache: map[string]provider.Resolution{}, + } +} + +// Name identifies this provider in output and config. +func (a *Adapter) Name() string { return "keepass" } + +// CheckReadiness verifies that keepassxc-cli is installed and that the +// configured database file exists and is openable with the current password. +// This is what `ds doctor` calls. +func (a *Adapter) CheckReadiness(ctx context.Context) (provider.Status, error) { + status := provider.Status{Provider: "keepass", CLIInstalled: true} + + // Check 1: is the binary on PATH? + if _, err := exec.LookPath(a.client.Bin); err != nil { + status.CLIInstalled = false + status.Code = "E001" + status.Problem = "keepassxc-cli not installed" + status.Impact = "commands cannot reach KeePass database" + status.Action = "install keepassxc-cli or add it to PATH" + return status, nil + } + + // Check 2: does the database file exist? + if _, err := os.Stat(a.cfg.KeePassDatabase); err != nil { + status.Code = "E002" + status.Problem = fmt.Sprintf("KeePass database not found: %s", a.cfg.KeePassDatabase) + status.Impact = "commands cannot open the KeePass database" + status.Action = "check keepass_database path in .envsync.yaml" + return status, nil + } + + // Check 3: can we unlock the database? + // This is the step that prompts for the password if not already set. + if err := a.ensurePassword(ctx); err != nil { + status.Code = "E003" + status.Problem = "KeePass database could not be unlocked" + status.Impact = "commands cannot read secrets from KeePass" + status.Action = "check your master password and retry" + return status, nil + } + + // Check 4: can we reach the configured group? + _, err := a.client.ListGroup(ctx, a.cfg.KeePassDatabase, a.cfg.KeePassGroup) + if err != nil { + status.Code = "E004" + status.Problem = fmt.Sprintf("KeePass group %q not found or not readable", a.cfg.KeePassGroup) + status.Impact = "sync cannot resolve provider-managed schema keys" + status.Action = "check keepass_group in .envsync.yaml" + return status, nil + } + + status.Authenticated = true + status.Unlocked = true + status.Message = "keepassxc-cli is ready" + return status, nil +} + +// Resolve fetches the secret value for a single env key from KeePass. +// The entry is looked up at / inside the database. +// Results are cached so the same key is only fetched once per run. +func (a *Adapter) Resolve(ctx context.Context, key, _ string) (provider.Resolution, error) { + // The second argument (ref) is the provider-specific field name used by + // Bitwarden's field mapping. KeePass uses the entry path directly, so we + // ignore it and derive the path from the group + key name. + cacheKey := a.cfg.KeePassGroup + "/" + key + + a.mu.Lock() + if cached, ok := a.cache[cacheKey]; ok { + a.mu.Unlock() + return cached, nil + } + a.mu.Unlock() + + // Prompt for the password on the first real resolution if not yet set. + if err := a.ensurePassword(ctx); err != nil { + return provider.Resolution{}, err + } + + entryPath := cacheKey // e.g. "dotenv/DATABASE_URL" + value, err := a.client.Show(ctx, a.cfg.KeePassDatabase, entryPath) + + resolution := provider.Resolution{Key: key, Ref: entryPath} + if err != nil { + if err == ErrItemNotFound { + resolution.Source = "missing" + resolution.IssueCode = "E005" + } else { + resolution.Source = "error" + resolution.IssueCode = "E003" + } + } else { + resolution.Source = "provider" + resolution.Value = value + } + + a.mu.Lock() + a.cache[cacheKey] = resolution + a.mu.Unlock() + + return resolution, nil +} + +// ResolveMany fetches secrets for multiple keys in one go. +// Each key is resolved individually (keepassxc-cli has no batch mode), +// but results are cached so the password is only prompted once. +func (a *Adapter) ResolveMany(ctx context.Context, refs map[string]string) (map[string]provider.Resolution, error) { + results := make(map[string]provider.Resolution, len(refs)) + for key, ref := range refs { + res, err := a.Resolve(ctx, key, ref) + if err != nil { + return nil, err + } + results[key] = res + } + return results, nil +} + +// SetPassword pre-seeds the master password without prompting. +// Used by ds scaffold when the password was already collected during first-run setup. +func (a *Adapter) SetPassword(password string) { + a.mu.Lock() + defer a.mu.Unlock() + a.client.Password = password +} + +// EnsurePassword is the public form of ensurePassword, used by ds scaffold +// which needs to prompt for the password before making multiple CLI calls. +func (a *Adapter) EnsurePassword(ctx context.Context) error { + return a.ensurePassword(ctx) +} + +// CreateEntry creates a blank entry in the KeePass group for the given key. +// Returns ErrEntryExists if the entry already exists — callers should skip +// rather than treat this as an error. +func (a *Adapter) CreateEntry(ctx context.Context, key string) error { + if err := a.ensurePassword(ctx); err != nil { + return err + } + return a.client.CreateEntry(ctx, a.cfg.KeePassDatabase, a.cfg.KeePassGroup, key, "") +} + +// ensurePassword prompts the user for the KeePass master password exactly once +// per process run. Subsequent calls return immediately because the password is +// already stored in the client. +// +// The prompt writes to stderr so it does not pollute stdout (which may be +// parsed by scripts). The password is read without echo using golang.org/x/term. +func (a *Adapter) ensurePassword(ctx context.Context) error { + a.mu.Lock() + defer a.mu.Unlock() + + // Already have the password from a previous call — nothing to do. + if a.client.Password != "" { + return nil + } + + // Print the prompt to stderr so stdout stays clean. + fmt.Fprintf(os.Stderr, "Enter KeePass master password for %s: ", a.cfg.KeePassDatabase) + + // term.ReadPassword reads a line without echoing it to the terminal. + // It takes the file descriptor of stdin (0). + pw, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) // newline after the hidden input + if err != nil { + return fmt.Errorf("could not read master password: %w", err) + } + + a.client.Password = strings.TrimSpace(string(pw)) + return nil +} \ No newline at end of file diff --git a/internal/provider/keepass/adapter_test.go b/internal/provider/keepass/adapter_test.go new file mode 100644 index 0000000..fdff802 --- /dev/null +++ b/internal/provider/keepass/adapter_test.go @@ -0,0 +1,261 @@ +package keepass + +import ( + "context" + "os" + "path/filepath" + "testing" + + "dotenv-sync/internal/config" + "dotenv-sync/internal/provider" +) + +// adapterWithStub builds an Adapter wired to a stub binary with the given password already set. +// This bypasses the interactive password prompt for testing. +func adapterWithStub(t *testing.T, bin, dbPath, group, password string) *Adapter { + t.Helper() + cfg := config.Config{ + KeePassDatabase: dbPath, + KeePassGroup: group, + } + a := NewAdapter(cfg) + a.client.Bin = bin + a.client.Password = password // pre-set so ensurePassword is a no-op + return a +} + +func TestAdapterNameIsKeepass(t *testing.T) { + a := NewAdapter(config.Config{}) + if a.Name() != "keepass" { + t.Fatalf("expected name=keepass, got %q", a.Name()) + } +} + +func TestAdapterResolveReturnsValue(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.kdbx") + // Create a dummy db file so the path exists + if err := os.WriteFile(dbPath, []byte("dummy"), 0o600); err != nil { + t.Fatal(err) + } + bin := stubBin(t, dir, "keepassxc-cli", ` +if [ "$1" = "show" ] && [ "$2" = "-s" ]; then + printf "Title: DATABASE_URL\nUserName: \nPassword: postgres://vault/dev\nURL: \nNotes: \n" + exit 0 +fi +exit 1 +`) + a := adapterWithStub(t, bin, dbPath, "dotenv", "masterpassword") + res, err := a.Resolve(context.Background(), "DATABASE_URL", "") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if res.Source != "provider" || res.Value != "postgres://vault/dev" { + t.Fatalf("unexpected resolution: %+v", res) + } +} + +func TestAdapterResolveUsesCacheOnSecondCall(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.kdbx") + if err := os.WriteFile(dbPath, []byte("dummy"), 0o600); err != nil { + t.Fatal(err) + } + callCount := 0 + // We can't easily count calls inside a shell script, so we use a log file. + logFile := filepath.Join(dir, "calls.log") + bin := stubBin(t, dir, "keepassxc-cli", ` +echo "$@" >> '`+logFile+`' +if [ "$1" = "show" ]; then + printf "Title: KEY\nPassword: somevalue\nNotes: \n" + exit 0 +fi +exit 1 +`) + _ = callCount + a := adapterWithStub(t, bin, dbPath, "dotenv", "pw") + + // First call hits the binary + if _, err := a.Resolve(context.Background(), "MY_KEY", ""); err != nil { + t.Fatalf("first resolve: %v", err) + } + // Second call should use cache — binary not invoked again + if _, err := a.Resolve(context.Background(), "MY_KEY", ""); err != nil { + t.Fatalf("second resolve: %v", err) + } + + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatal(err) + } + // Count how many times show was called + lines := 0 + for _, line := range splitLines(string(data)) { + if line != "" { + lines++ + } + } + if lines != 1 { + t.Fatalf("expected 1 CLI invocation (cache hit on 2nd), got %d\nlog: %s", lines, data) + } +} + +func TestAdapterResolveMissingKeyReturnsE005(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.kdbx") + if err := os.WriteFile(dbPath, []byte("dummy"), 0o600); err != nil { + t.Fatal(err) + } + bin := stubBin(t, dir, "keepassxc-cli", ` +echo "Entry not found." >&2 +exit 1 +`) + a := adapterWithStub(t, bin, dbPath, "dotenv", "pw") + res, err := a.Resolve(context.Background(), "MISSING_KEY", "") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if res.Source != "missing" || res.IssueCode != "E005" { + t.Fatalf("expected missing/E005, got %+v", res) + } +} + +func TestAdapterResolveManyReturnsAllResolutions(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.kdbx") + if err := os.WriteFile(dbPath, []byte("dummy"), 0o600); err != nil { + t.Fatal(err) + } + bin := stubBin(t, dir, "keepassxc-cli", ` +if [ "$1" = "show" ] && [ "$4" = "dotenv/DATABASE_URL" ]; then + printf "Title: DATABASE_URL\nPassword: postgres://vault/dev\nNotes: \n" + exit 0 +fi +if [ "$1" = "show" ] && [ "$4" = "dotenv/JWT_SECRET" ]; then + printf "Title: JWT_SECRET\nPassword: topsecret\nNotes: \n" + exit 0 +fi +echo "Entry not found." >&2 +exit 1 +`) + a := adapterWithStub(t, bin, dbPath, "dotenv", "pw") + results, err := a.ResolveMany(context.Background(), map[string]string{ + "DATABASE_URL": "", + "JWT_SECRET": "", + }) + if err != nil { + t.Fatalf("ResolveMany: %v", err) + } + if results["DATABASE_URL"].Value != "postgres://vault/dev" { + t.Fatalf("unexpected DATABASE_URL: %+v", results["DATABASE_URL"]) + } + if results["JWT_SECRET"].Value != "topsecret" { + t.Fatalf("unexpected JWT_SECRET: %+v", results["JWT_SECRET"]) + } +} + +func TestAdapterCheckReadinessBinaryMissing(t *testing.T) { + a := NewAdapter(config.Config{ + KeePassDatabase: "/some/db.kdbx", + KeePassGroup: "dotenv", + }) + a.client.Bin = "/nonexistent/keepassxc-cli" + + status, err := a.CheckReadiness(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status.CLIInstalled { + t.Fatal("expected CLIInstalled=false") + } + if status.Code != "E001" { + t.Fatalf("expected E001, got %q", status.Code) + } +} + +func TestAdapterCheckReadinessDatabaseMissing(t *testing.T) { + dir := t.TempDir() + bin := stubBin(t, dir, "keepassxc-cli", `exit 0`) + a := NewAdapter(config.Config{ + KeePassDatabase: "/nonexistent/db.kdbx", + KeePassGroup: "dotenv", + }) + a.client.Bin = bin + a.client.Password = "pw" + + status, err := a.CheckReadiness(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status.Code != "E002" { + t.Fatalf("expected E002 for missing db, got %q", status.Code) + } +} + +func TestAdapterCheckReadinessGroupMissing(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.kdbx") + if err := os.WriteFile(dbPath, []byte("dummy"), 0o600); err != nil { + t.Fatal(err) + } + bin := stubBin(t, dir, "keepassxc-cli", ` +echo "Could not find entry nosuchgroup." >&2 +exit 1 +`) + a := adapterWithStub(t, bin, dbPath, "nosuchgroup", "pw") + + status, err := a.CheckReadiness(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status.Code != "E004" { + t.Fatalf("expected E004 for missing group, got %q", status.Code) + } +} + +func TestAdapterCheckReadinessReady(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.kdbx") + if err := os.WriteFile(dbPath, []byte("dummy"), 0o600); err != nil { + t.Fatal(err) + } + bin := stubBin(t, dir, "keepassxc-cli", ` +if [ "$1" = "ls" ]; then + printf "DATABASE_URL\n" + exit 0 +fi +exit 1 +`) + a := adapterWithStub(t, bin, dbPath, "dotenv", "pw") + + status, err := a.CheckReadiness(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !status.Authenticated || !status.Unlocked { + t.Fatalf("expected ready status, got %+v", status) + } + if status.Code != "" { + t.Fatalf("expected no error code, got %q", status.Code) + } +} + +// Verify Adapter satisfies the provider.Provider interface at compile time. +var _ provider.Provider = (*Adapter)(nil) + +// splitLines is a small helper to split a string into non-empty lines. +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + line := s[start:i] + lines = append(lines, line) + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} \ No newline at end of file diff --git a/internal/provider/keepass/kpxc_client.go b/internal/provider/keepass/kpxc_client.go new file mode 100644 index 0000000..f3f9b08 --- /dev/null +++ b/internal/provider/keepass/kpxc_client.go @@ -0,0 +1,210 @@ +package keepass + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strings" +) + +// ErrBinaryMissing is returned when keepassxc-cli is not found on PATH. +var ErrBinaryMissing = errors.New("keepassxc-cli binary missing") + +// ErrItemNotFound is returned when the requested entry does not exist in the database. +var ErrItemNotFound = errors.New("keepassxc-cli entry not found") + +// ErrEntryExists is returned when trying to create an entry that already exists. +var ErrEntryExists = errors.New("keepassxc-cli entry already exists") + +// KPXCClient wraps the keepassxc-cli binary. +// It holds the path to the binary and the master password for the database. +// The password is prompted once by the Adapter and stored here for the +// lifetime of a single ds sync run — it is never written to disk. +type KPXCClient struct { + // Bin is the name or path of the keepassxc-cli binary. + // Defaults to "keepassxc-cli". + Bin string + + // Password is the master password for the KeePass database. + // Supplied once at startup and reused for every CLI call. + Password string +} + +// NewKPXCClient returns a client using the default binary name. +// Password is set later by the Adapter after prompting the user. +func NewKPXCClient() *KPXCClient { + return &KPXCClient{Bin: "keepassxc-cli"} +} + +// Show retrieves a single entry from the database and returns the value of +// its Password field. +// +// entryPath is the full path to the entry inside the database, e.g. +// "dotenv/DATABASE_URL". databasePath is the path to the .kdbx file. +// +// Equivalent shell command: +// +// echo "" | keepassxc-cli show -s +func (c *KPXCClient) Show(ctx context.Context, databasePath, entryPath string) (string, error) { + out, err := c.run(ctx, databasePath, "show", "-s", databasePath, entryPath) + if err != nil { + return "", err + } + return parsePasswordField(out) +} + +// ListGroup returns the entry titles inside a group, confirming the group +// exists and is reachable. +// +// Equivalent shell command: +// +// echo "" | keepassxc-cli ls +func (c *KPXCClient) ListGroup(ctx context.Context, databasePath, group string) ([]string, error) { + out, err := c.run(ctx, databasePath, "ls", databasePath, group) + if err != nil { + return nil, err + } + // Each line is an entry title or sub-group (sub-groups end with "/"). + var entries []string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.HasSuffix(line, "/") { + entries = append(entries, line) + } + } + return entries, nil +} + +// run executes keepassxc-cli, piping the master password via stdin. +// args are the CLI arguments that follow the binary name. +// databasePath is separated out so we can check for not-found errors. +func (c *KPXCClient) run(ctx context.Context, databasePath string, args ...string) (string, error) { + // Make sure the binary exists on PATH before trying to run it. + if _, err := exec.LookPath(c.Bin); err != nil { + return "", ErrBinaryMissing + } + + cmd := exec.CommandContext(ctx, c.Bin, args...) + + // keepassxc-cli reads the master password from stdin when it is not a TTY. + // We write "\n" to stdin so the prompt is answered automatically. + cmd.Stdin = strings.NewReader(c.Password + "\n") + + out, err := cmd.CombinedOutput() + // CombinedOutput captures both stdout and stderr together. + text := strings.TrimSpace(string(out)) + + if err != nil { + if isNotFoundText(text, err) { + return "", ErrItemNotFound + } + if text == "" { + return "", fmt.Errorf("keepassxc-cli %s failed: %w", strings.Join(args, " "), err) + } + return "", fmt.Errorf("%s", text) + } + return text, nil +} + +// CreateEntry creates a new entry in the database under the given group. +// The entry title is set to entryName and the password field is left blank — +// the user fills in the real value in KeePassXC after scaffolding. +// +// Returns ErrEntryExists if the entry already exists — callers should skip +// rather than treat this as an error. +// +// Equivalent shell command: +// +// echo "" | keepassxc-cli add -p / +func (c *KPXCClient) CreateEntry(ctx context.Context, databasePath, group, entryName, entryPassword string) error { + if _, err := exec.LookPath(c.Bin); err != nil { + return ErrBinaryMissing + } + + // keepassxc-cli gives the same "Could not create entry" message for both + // "already exists" and genuine failures, so we can't distinguish from the + // output alone. Instead, check existence first with a ListGroup call. + entries, err := c.ListGroup(ctx, databasePath, group) + if err != nil { + return err + } + for _, e := range entries { + if e == entryName { + return ErrEntryExists + } + } + + entryPath := group + "/" + entryName + // keepassxc-cli add -p reads two lines from stdin: + // line 1: database master password + // line 2: new entry password (prompted by -p flag) + input := c.Password + "\n" + entryPassword + "\n" + + cmd := exec.CommandContext(ctx, c.Bin, "add", "-p", databasePath, entryPath) + cmd.Stdin = strings.NewReader(input) + out, err := cmd.CombinedOutput() + text := strings.TrimSpace(string(out)) + if err != nil { + if text == "" { + return fmt.Errorf("keepassxc-cli add failed: %w", err) + } + return fmt.Errorf("%s", text) + } + return nil +} + +// parsePasswordField scans the output of `keepassxc-cli show -s` and +// extracts the value after "Password: ". +// +// Example output: +// +// Title: DATABASE_URL +// UserName: +// Password: postgres://localhost:5432/testdb +// URL: +// Notes: +func parsePasswordField(output string) (string, error) { + for _, line := range strings.Split(output, "\n") { + // strings.CutPrefix is available in Go 1.20+; we use HasPrefix + TrimPrefix + // to stay compatible with Go 1.22 (which has CutPrefix, so either works). + if strings.HasPrefix(line, "Password:") { + value := strings.TrimPrefix(line, "Password:") + return strings.TrimSpace(value), nil + } + } + return "", fmt.Errorf("keepassxc-cli output did not contain a Password field") +} + +// isNotFoundText checks whether the error output indicates a missing entry +// rather than a genuine failure (wrong password, corrupt db, etc.). +func isNotFoundText(parts ...any) bool { + var b strings.Builder + for i, part := range parts { + if i > 0 { + b.WriteByte(' ') + } + switch v := part.(type) { + case string: + b.WriteString(v) + case error: + b.WriteString(v.Error()) + default: + b.WriteString(fmt.Sprint(v)) + } + } + lower := strings.ToLower(b.String()) + needles := []string{ + "not found", + "no such entry", + "entry not found", + "could not find", + "no entry", + } + for _, needle := range needles { + if strings.Contains(lower, needle) { + return true + } + } + return false +} \ No newline at end of file diff --git a/internal/provider/keepass/kpxc_client_test.go b/internal/provider/keepass/kpxc_client_test.go new file mode 100644 index 0000000..2c16ea4 --- /dev/null +++ b/internal/provider/keepass/kpxc_client_test.go @@ -0,0 +1,167 @@ +package keepass + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +// stubBin writes a shell script to dir/ and returns its path. +// The script is made executable so exec.LookPath and exec.Command can use it. +func stubBin(t *testing.T, dir, name, script string) string { + t.Helper() + bin := filepath.Join(dir, name) + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"+script), 0o755); err != nil { + t.Fatal(err) + } + return bin +} + +// clientWithBin returns a KPXCClient wired to a stub binary with the given password. +func clientWithBin(bin, password string) *KPXCClient { + return &KPXCClient{Bin: bin, Password: password} +} + +// --- parsePasswordField --- + +func TestParsePasswordFieldExtractsValue(t *testing.T) { + output := "Title: DATABASE_URL\nUserName: \nPassword: postgres://localhost:5432/testdb\nURL: \nNotes: \n" + got, err := parsePasswordField(output) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "postgres://localhost:5432/testdb" { + t.Fatalf("expected postgres URL, got %q", got) + } +} + +func TestParsePasswordFieldHandlesEmptyValue(t *testing.T) { + output := "Title: MY_KEY\nPassword: \nNotes: \n" + got, err := parsePasswordField(output) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Fatalf("expected empty string, got %q", got) + } +} + +func TestParsePasswordFieldErrorsWhenMissing(t *testing.T) { + output := "Title: MY_KEY\nNotes: no password line here\n" + _, err := parsePasswordField(output) + if err == nil { + t.Fatal("expected error when Password field missing") + } +} + +// --- isNotFoundText --- + +func TestIsNotFoundTextMatchesKnownPhrases(t *testing.T) { + cases := []string{ + "Entry not found.", + "Could not find entry dotenv/MISSING_KEY", + "no such entry", + "no entry for this path", + } + for _, c := range cases { + if !isNotFoundText(c) { + t.Errorf("expected isNotFoundText(%q) = true", c) + } + } +} + +func TestIsNotFoundTextIgnoresUnrelatedErrors(t *testing.T) { + cases := []string{ + "Invalid credentials were provided", + "HMAC mismatch", + "database file is locked", + } + for _, c := range cases { + if isNotFoundText(c) { + t.Errorf("expected isNotFoundText(%q) = false", c) + } + } +} + +// --- KPXCClient.Show --- + +func TestKPXCClientShowReturnsPasswordValue(t *testing.T) { + dir := t.TempDir() + bin := stubBin(t, dir, "keepassxc-cli", ` +if [ "$1" = "show" ] && [ "$2" = "-s" ]; then + printf "Title: DATABASE_URL\nUserName: \nPassword: postgres://vault/dev\nURL: \nNotes: \n" + exit 0 +fi +echo "unexpected args" >&2 +exit 1 +`) + client := clientWithBin(bin, "masterpassword") + value, err := client.Show(context.Background(), "test.kdbx", "dotenv/DATABASE_URL") + if err != nil { + t.Fatalf("Show: %v", err) + } + if value != "postgres://vault/dev" { + t.Fatalf("expected postgres URL, got %q", value) + } +} + +func TestKPXCClientShowReturnsErrItemNotFound(t *testing.T) { + dir := t.TempDir() + bin := stubBin(t, dir, "keepassxc-cli", ` +echo "Entry not found." >&2 +exit 1 +`) + client := clientWithBin(bin, "masterpassword") + _, err := client.Show(context.Background(), "test.kdbx", "dotenv/MISSING") + if !errors.Is(err, ErrItemNotFound) { + t.Fatalf("expected ErrItemNotFound, got %v", err) + } +} + +func TestKPXCClientShowReturnsErrBinaryMissing(t *testing.T) { + client := clientWithBin("/nonexistent/keepassxc-cli", "pw") + _, err := client.Show(context.Background(), "test.kdbx", "dotenv/KEY") + if !errors.Is(err, ErrBinaryMissing) { + t.Fatalf("expected ErrBinaryMissing, got %v", err) + } +} + +// --- KPXCClient.ListGroup --- + +func TestKPXCClientListGroupReturnsEntries(t *testing.T) { + dir := t.TempDir() + bin := stubBin(t, dir, "keepassxc-cli", ` +if [ "$1" = "ls" ]; then + printf "DATABASE_URL\nJWT_SECRET\nsubgroup/\n" + exit 0 +fi +exit 1 +`) + client := clientWithBin(bin, "masterpassword") + entries, err := client.ListGroup(context.Background(), "test.kdbx", "dotenv") + if err != nil { + t.Fatalf("ListGroup: %v", err) + } + // subgroup/ should be filtered out, leaving two entries + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d: %v", len(entries), entries) + } + if entries[0] != "DATABASE_URL" || entries[1] != "JWT_SECRET" { + t.Fatalf("unexpected entries: %v", entries) + } +} + +func TestKPXCClientListGroupReturnsErrItemNotFound(t *testing.T) { + dir := t.TempDir() + bin := stubBin(t, dir, "keepassxc-cli", ` +echo "Could not find entry nosuchgroup." >&2 +exit 1 +`) + client := clientWithBin(bin, "masterpassword") + _, err := client.ListGroup(context.Background(), "test.kdbx", "nosuchgroup") + if !errors.Is(err, ErrItemNotFound) { + t.Fatalf("expected ErrItemNotFound, got %v", err) + } +} \ No newline at end of file