From 9b46ecb842d4b9d94d49a61bf5635e0c3aafbed3 Mon Sep 17 00:00:00 2001 From: gregoftheweb Date: Mon, 25 May 2026 09:55:29 -0700 Subject: [PATCH 1/8] feat: add KeePass provider support via keepassxc-cli --- .env.example | 2 + .envsync.yaml | 5 + go.mod | 4 +- go.sum | 4 + internal/cli/doctor.go | 4 +- internal/cli/init.go | 119 ++++++++++++++- internal/cli/root.go | 12 +- internal/config/config.go | 15 +- internal/provider/keepass/adapter.go | 183 +++++++++++++++++++++++ internal/provider/keepass/kpxc_client.go | 160 ++++++++++++++++++++ test-secrets.kdbx | Bin 0 -> 2165 bytes 11 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 .env.example create mode 100644 .envsync.yaml create mode 100644 internal/provider/keepass/adapter.go create mode 100644 internal/provider/keepass/kpxc_client.go create mode 100644 test-secrets.kdbx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..18b3d5c --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Database connection +DATABASE_URL= 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/go.mod b/go.mod index af1e084..2422a0f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module dotenv-sync -go 1.22 +go 1.25.0 require ( github.com/spf13/cobra v1.8.1 @@ -10,4 +10,6 @@ require ( 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 + golang.org/x/term v0.43.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..e5b3c3f 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -24,8 +24,8 @@ 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 }, } -} +} \ No newline at end of file diff --git a/internal/cli/init.go b/internal/cli/init.go index 32d5af8..47db0c3 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -1,24 +1,50 @@ package cli import ( + "bufio" + "context" "fmt" + "os" + "strings" + "dotenv-sync/internal/config" "dotenv-sync/internal/envfile" + "dotenv-sync/internal/provider/keepass" "dotenv-sync/internal/report" syncpkg "dotenv-sync/internal/sync" "github.com/spf13/cobra" + "golang.org/x/term" ) 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) if err != nil { return err } + + // First-run setup: if no .envsync.yaml exists yet and we are + // running interactively (stdin is a TTY), ask the user which + // provider they want and write the config file before continuing. + // When stdin is not a TTY (tests, pipes, CI) we skip setup and + // let the normal init behaviour run with the default config. + _, statErr := os.Stat(cfg.ConfigFile) + isInteractive := term.IsTerminal(int(os.Stdin.Fd())) + if os.IsNotExist(statErr) && !dryRun && isInteractive { + if err := runFirstTimeSetup(s, cfg); err != nil { + // Setup was cancelled or failed — message already printed, exit cleanly. + return nil + } + // Setup succeeded — config is written. Stop here and let the + // user run ds sync. Don't try to run PlanInit which needs .env. + 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)) @@ -45,3 +71,94 @@ func newInitCommand(s streams, opts *rootOptions) *cobra.Command { cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview without writing") return cmd } + +// runFirstTimeSetup prompts the user for provider preferences and writes +// .envsync.yaml. It is only called when no config file exists yet. +// Nothing is written to disk until all inputs are collected and validated. +func runFirstTimeSetup(s streams, cfg config.Config) error { + reader := bufio.NewReader(os.Stdin) + + fmt.Fprintln(s.stdout, "No .envsync.yaml found. Let's set up dotenv-sync.") + fmt.Fprintln(s.stdout, "") + + // --- Collect all inputs first --- + + // 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" + } + + // Validate provider before asking any further questions. + if providerInput != "bitwarden" && providerInput != "keepass" { + fmt.Fprintf(s.stdout, "Setup failed: unknown provider %q — must be bitwarden or keepass. Please run ds init again.\n", providerInput) + return fmt.Errorf("setup cancelled") + } + + var dbPath, groupName string + + if providerInput == "keepass" { + // Ask for the database path. + 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 ds init again.") + return fmt.Errorf("setup cancelled") + } + + // Validate the file exists before asking anything else. + if _, err := os.Stat(dbPath); err != nil { + fmt.Fprintf(s.stdout, "Setup failed: database file not found at %q. Please run ds init again.\n", dbPath) + return fmt.Errorf("setup cancelled") + } + + // Ask for the group name, defaulting to "dotenv". + fmt.Fprint(s.stdout, "Group name for env vars (default: dotenv): ") + groupName, _ = reader.ReadString('\n') + groupName = strings.TrimSpace(groupName) + if groupName == "" { + groupName = "dotenv" + } + + // Prompt for master password and verify the group actually exists + // in the vault. Nothing is written to disk until this passes. + 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 ds init again.") + return fmt.Errorf("setup cancelled") + } + client.Password = strings.TrimSpace(string(pw)) + + _, err = client.ListGroup(context.Background(), dbPath, groupName) + if err != nil { + fmt.Fprintf(s.stdout, "Setup failed: group %q not found in %s. Please run ds init again.\n", groupName, dbPath) + return fmt.Errorf("setup cancelled") + } + } + + // --- All inputs valid — now build and 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", "init 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 nil +} \ No newline at end of file diff --git a/internal/cli/root.go b/internal/cli/root.go index 40b7c43..e64f5e5 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) } @@ -95,4 +103,4 @@ func providerFor(cfg config.Config) provider.Provider { func pushProviderFor(cfg config.Config) provider.PushProvider { return pushProviderFactory(cfg) -} +} \ 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..78224af --- /dev/null +++ b/internal/provider/keepass/adapter.go @@ -0,0 +1,183 @@ +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 +} + +// 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/kpxc_client.go b/internal/provider/keepass/kpxc_client.go new file mode 100644 index 0000000..3bc1669 --- /dev/null +++ b/internal/provider/keepass/kpxc_client.go @@ -0,0 +1,160 @@ +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") + +// 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 +} + +// 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/test-secrets.kdbx b/test-secrets.kdbx new file mode 100644 index 0000000000000000000000000000000000000000..8d382b7c2701597e67743301fb8469c8641c323c GIT binary patch literal 2165 zcmV-*2#WUu*`k_f`%AR|00aO65C8xGF~RcYzi~rQzE}kzYW!ON0|Wp70096100bZa z000@A-Qy2w{1234qmOQUdbJFh=SI6e=rIP>0MNHB%1#Fm0002R`rdO!#I0b0G!=Lf zU%yHVivR!s00BY;0000aRaHqu5C8xG?_+J>j44D*k@u;j1LFz|1pxp607(b{000;O z00000000F60000@2mk;800004000001OWg508j(~000C4002S(0000}AOHXWCQ1OWg509FJ5000vJ000001ONa44GIkk z7{f+sy2doRZx~{W)yO2p`{0&f7X@Rxnoa>f}mr0nn0CnJimu~M?xOjB*BdT6a zma*__)!owhVQhNdib6jv&^WUfJz&1S7}M}_ll;|a%FE|cN1D**aR=*J@CEU5FU7 z0&f;#`V0vsJEYd+3pjOjb`wdCl9Qn&s1j~lUrj=lubs?Jz|wllv!A9Z>C=Citl1}l z+QfA?=?;NkEZN;Uq0XN0F`lBW9EqU1Y7-DR?9Un$HjSs!dX=+(yADZ<%x*mx`(o&d+ zQ~RT+09`6|(45)KGhL^DxXOX89xz!Xxn?}W>m6OLX;?m7=4(U>#yAx)7|h794P=e$&sX=ZN%?P75>})gn|N799tneJ$90$h)k<7dS!#ns4RE{jvjA`E^ z0WVt;B3|1AlQ)Pb3%${$P=S%BH2d?ind%*}2mzLdL!{a$*jjCK1gedD0O0b@7?oO?=rihSn9VoT*I~N zS+xVyv?ur@PI{gW7j@3)#;)nW zItC{c7Owp9D&N^TWL&r=9L+k$f!aCbLtk;PJIJakpVNT9;m#Ppfs)dyI7zc(7>vBUJK2D-{5*c1pDND z+-aYt1qw&)F#<+5he`Zr9EF|h)V*OrB>rsLLYC2qfqDK3#C$Ncr|N1yJsiIBzPo)P zsVxM4aGU|;1s-Z9Mb=Wl%1ACbq_ua7-%*5g3HSSqb$R$ShhCn?MN6jf?TM51_Gk_^tN@Ba7*8abmU>GyA)XE8H=!ot|>0Dp&h(Wl`xW z-o>-dcumW@Im0O?OFdkBuF)QHnmL^NG!MxT30^2P*Hc95S9YC_`%IP%?+K7%n1I2M zd#Jf1zhyIA&yVi7A|TW&A!3(@rY)y}{&5%Q^yc?U!PR!rDJMH#F!i@9K@Iu8q?EQD z=j+yo-jbADG1^#)WZA#^`aS5ybbx<|o;0tZT&{c-*T`_55rYv|6$t3Y;D0RM-fxK# z2ZwQG-(N(7GppLQ%Zj0op`bc!B9F}kwz7uOVLynjZveOIw>17=$$KIq{~EyIq>G{M z+^^-1=V-RkI1hAr=t$mDZz?DE< zL$DXQxesB$yKru^`+#1y#+3gf6ceFF|39&VV2C7qJNS;uhH&9xAA6^S^PCvGb{4oI z)}j;T2DPwOZ8#OZQUr?i0T{rwco|{e$BJwJsuty~{yJAvQJAuoQkA1>70ZzzzqoQe z#77o;#cI~5sXbz;xF!lKcD!`(*p@SA$+97}zs7~p)C*1hq@@WR zA@&Ai0Y%>D)c9`|BE12Fj&J8s%=2aiPUSxlmCFDR<)k1ri_XCE=R4+DAxMY#3mt_@ z;b6Kci2=Ya&wJDar^f+tkp@6x{&W{00000=BoSk literal 0 HcmV?d00001 From b58a947e50305567e0332c3896387799c7a45b1b Mon Sep 17 00:00:00 2001 From: gregoftheweb Date: Mon, 25 May 2026 10:01:02 -0700 Subject: [PATCH 2/8] chore: remove test artifacts and ignore kdbx files --- .env.example | 2 -- .envsync.yaml | 5 ----- .gitignore | 1 + 3 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 .env.example delete mode 100644 .envsync.yaml diff --git a/.env.example b/.env.example deleted file mode 100644 index 18b3d5c..0000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -# Database connection -DATABASE_URL= diff --git a/.envsync.yaml b/.envsync.yaml deleted file mode 100644 index 7b8671f..0000000 --- a/.envsync.yaml +++ /dev/null @@ -1,5 +0,0 @@ -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 From 839f03ba90f428f2771142dbd057748b6a7b4650 Mon Sep 17 00:00:00 2001 From: gregoftheweb Date: Mon, 25 May 2026 10:12:39 -0700 Subject: [PATCH 3/8] test: add KeePass client and adapter tests --- internal/provider/keepass/adapter_test.go | 261 ++++++++++++++++++ internal/provider/keepass/kpxc_client_test.go | 167 +++++++++++ 2 files changed, 428 insertions(+) create mode 100644 internal/provider/keepass/adapter_test.go create mode 100644 internal/provider/keepass/kpxc_client_test.go 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_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 From 75f168e823cc0f1d891be25bfbb93a4eedbbc661 Mon Sep 17 00:00:00 2001 From: gregoftheweb Date: Mon, 25 May 2026 10:38:47 -0700 Subject: [PATCH 4/8] feat: add ds scaffold command for KeePass vault seeding --- .env.example | 4 + .envsync.yaml | 5 + internal/cli/init.go | 120 +-------------------- internal/cli/root.go | 1 + internal/cli/scaffold.go | 102 ++++++++++++++++++ internal/cli/setup.go | 127 +++++++++++++++++++++++ internal/provider/keepass/adapter.go | 16 ++- internal/provider/keepass/kpxc_client.go | 50 +++++++++ test-secrets.kdbx | Bin 2165 -> 2645 bytes 9 files changed, 309 insertions(+), 116 deletions(-) create mode 100644 .env.example create mode 100644 .envsync.yaml create mode 100644 internal/cli/scaffold.go create mode 100644 internal/cli/setup.go 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/internal/cli/init.go b/internal/cli/init.go index 47db0c3..34280de 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -1,19 +1,12 @@ package cli import ( - "bufio" - "context" "fmt" - "os" - "strings" - "dotenv-sync/internal/config" "dotenv-sync/internal/envfile" - "dotenv-sync/internal/provider/keepass" "dotenv-sync/internal/report" syncpkg "dotenv-sync/internal/sync" "github.com/spf13/cobra" - "golang.org/x/term" ) func newInitCommand(s streams, opts *rootOptions) *cobra.Command { @@ -22,25 +15,13 @@ func newInitCommand(s streams, opts *rootOptions) *cobra.Command { Use: "init", 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 + // Setup was cancelled or failed — exit cleanly. + return nil } - - // First-run setup: if no .envsync.yaml exists yet and we are - // running interactively (stdin is a TTY), ask the user which - // provider they want and write the config file before continuing. - // When stdin is not a TTY (tests, pipes, CI) we skip setup and - // let the normal init behaviour run with the default config. - _, statErr := os.Stat(cfg.ConfigFile) - isInteractive := term.IsTerminal(int(os.Stdin.Fd())) - if os.IsNotExist(statErr) && !dryRun && isInteractive { - if err := runFirstTimeSetup(s, cfg); err != nil { - // Setup was cancelled or failed — message already printed, exit cleanly. - return nil - } - // Setup succeeded — config is written. Stop here and let the - // user run ds sync. Don't try to run PlanInit which needs .env. + if setupRan { + // Config just written — tell the user what to do next and stop. fmt.Fprintln(s.stdout, "Run 'ds sync' to populate your .env file.") return nil } @@ -70,95 +51,4 @@ func newInitCommand(s streams, opts *rootOptions) *cobra.Command { } cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview without writing") return cmd -} - -// runFirstTimeSetup prompts the user for provider preferences and writes -// .envsync.yaml. It is only called when no config file exists yet. -// Nothing is written to disk until all inputs are collected and validated. -func runFirstTimeSetup(s streams, cfg config.Config) error { - reader := bufio.NewReader(os.Stdin) - - fmt.Fprintln(s.stdout, "No .envsync.yaml found. Let's set up dotenv-sync.") - fmt.Fprintln(s.stdout, "") - - // --- Collect all inputs first --- - - // 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" - } - - // Validate provider before asking any further questions. - if providerInput != "bitwarden" && providerInput != "keepass" { - fmt.Fprintf(s.stdout, "Setup failed: unknown provider %q — must be bitwarden or keepass. Please run ds init again.\n", providerInput) - return fmt.Errorf("setup cancelled") - } - - var dbPath, groupName string - - if providerInput == "keepass" { - // Ask for the database path. - 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 ds init again.") - return fmt.Errorf("setup cancelled") - } - - // Validate the file exists before asking anything else. - if _, err := os.Stat(dbPath); err != nil { - fmt.Fprintf(s.stdout, "Setup failed: database file not found at %q. Please run ds init again.\n", dbPath) - return fmt.Errorf("setup cancelled") - } - - // Ask for the group name, defaulting to "dotenv". - fmt.Fprint(s.stdout, "Group name for env vars (default: dotenv): ") - groupName, _ = reader.ReadString('\n') - groupName = strings.TrimSpace(groupName) - if groupName == "" { - groupName = "dotenv" - } - - // Prompt for master password and verify the group actually exists - // in the vault. Nothing is written to disk until this passes. - 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 ds init again.") - return fmt.Errorf("setup cancelled") - } - client.Password = strings.TrimSpace(string(pw)) - - _, err = client.ListGroup(context.Background(), dbPath, groupName) - if err != nil { - fmt.Fprintf(s.stdout, "Setup failed: group %q not found in %s. Please run ds init again.\n", groupName, dbPath) - return fmt.Errorf("setup cancelled") - } - } - - // --- All inputs valid — now build and 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", "init 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 nil } \ No newline at end of file diff --git a/internal/cli/root.go b/internal/cli/root.go index e64f5e5..e099b58 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -82,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..9661ce6 --- /dev/null +++ b/internal/cli/scaffold.go @@ -0,0 +1,102 @@ +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, err := ensureConfig(s, opts) + if err != nil { + // Setup was cancelled or failed — exit cleanly. + return nil + } + + // 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 KeePass client. ensurePassword will prompt once. + adapter := keepass.NewAdapter(cfg) + 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..1062369 --- /dev/null +++ b/internal/cli/setup.go @@ -0,0 +1,127 @@ +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 (true, nil) if setup ran and succeeded — the caller should reload +// config and continue. Returns (false, nil) if config already exists. +// Returns (false, err) if setup was cancelled or failed — the caller should +// stop cleanly without printing another error. +func ensureConfig(s streams, opts *rootOptions) (setupRan bool, cfg config.Config, 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 + } + + if err := runFirstTimeSetup(s, cfg); 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, nil +} + +// runFirstTimeSetup prompts the user for provider preferences and writes +// .envsync.yaml. Nothing is written to disk until all inputs are validated. +func runFirstTimeSetup(s streams, cfg config.Config) 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)) + + 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 nil +} \ No newline at end of file diff --git a/internal/provider/keepass/adapter.go b/internal/provider/keepass/adapter.go index 78224af..40870b2 100644 --- a/internal/provider/keepass/adapter.go +++ b/internal/provider/keepass/adapter.go @@ -152,7 +152,21 @@ func (a *Adapter) ResolveMany(ctx context.Context, refs map[string]string) (map[ return results, nil } -// ensurePassword prompts the user for the KeePass master password exactly once +// 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, "") +} // per process run. Subsequent calls return immediately because the password is // already stored in the client. // diff --git a/internal/provider/keepass/kpxc_client.go b/internal/provider/keepass/kpxc_client.go index 3bc1669..f3f9b08 100644 --- a/internal/provider/keepass/kpxc_client.go +++ b/internal/provider/keepass/kpxc_client.go @@ -14,6 +14,9 @@ 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 @@ -104,6 +107,53 @@ func (c *KPXCClient) run(ctx context.Context, databasePath string, args ...strin 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: ". // diff --git a/test-secrets.kdbx b/test-secrets.kdbx index 8d382b7c2701597e67743301fb8469c8641c323c..a5c1db97e1676cb0156020e10f8b7bf4e6df550f 100644 GIT binary patch delta 2542 zcmVy(Z#g2jB&kGAYCVqIyJCe8ttzNtsO-M#|4mqL1l8ptA5Q z@cH9es7EPUY{68)oebgT);Hear=*rJPk&h@jn`NrLtzhAN{8vlAZ`5fT=shra>wIG ze4?4Ggy-G%d0S;qVW)t#P30|xk`W_g3K=naByC|ngS%I;nmtIO+Eyi%9eb8NjY)C# z8`N#;Ib|FPR}MhIPG>+yK7G>cvgUXaW~=lRM15}ouAjq3X=3`LT5fa6)$rKXaeue{ zE3y>2%sn0l-6$0YfbEE9Jq6m!9n|9viPx65hR#~Fh{#yPM_4h83I7Wpn+2}Y>t=NA zyY87HWPJR|Aw!NF-b!w}G-{Yyp3w%E%YycG1K2FfS|{GYkwwcSd|*LvR0v6!mHb?{CcNodKpq~!abJBD~rl5 zP4Ku@Ez7kHYbnj`slP+tFixz~1CdE$jY!@}ilo6Ig^ira;OLS38dy7^h@#GrZ8D1TzSigNckRKu4q znN@XnX_Y|o!&apJ&h@eDp9KCzXP|;JM%_-xyoc8!cB8s285G_Mzs0^$>fWUAAW!21 z%e>JC+*QQS)P}dlzG&Ew;Nmwi&bou3pTA9aekY<^OnqE^oNySHH zAyMrRd?xBK7NDG?NvQZ@`4rQOu*ouHA2RpLCs_cGYV8a!eY+3DIk z+DQ#M9M4WZvKX{1E8%#?SQp!F`kAG??vN?HxWQ7cqlP~rqSFr-GA=9fnA6pYM2{%J za1idg+#NPYDSx}=|4H=Wa~3S#qsg?H$ilxbD78b~wDgsQ&#+Kx?aA~;5f~bn*v^k= z6v!k%6tHH!livndxi%$b>&RBw3O_Q~5NLwt8PO5La1!PkA~J7QaLJT7oBo3vVDSyWP*$ZYJWZCKx%p5)#o}QDjUOMscIk# z&Y^cZKuw~)x$S7P_Y4+V&)As#7Znp++2$`q zz%0NC6o3Aw5=$o(f%P-{u4sJ|8Td$yGK#_~OJ15NGag!_z6MkCBVd6=c_t?qMu^25 z%JnjO$jfa^0E%L$6mWWR?mH_3=}(c?R@Uw%fTO|ZT8TsLIg?KNLK$C<^GE1HYcZsl zT|$jATO@N+0w&$^1ZNa%)`6upCZNvh&NTK_Ab;51pIs@RC$HgVe3ydUg#u1e+C__v zlPvKoVk85oh!6YJT3^5xFb$Sm4FOI=QS&b6Mi$%N(I%nFa!B-Maw9TX z2(sbuR8Yd+CFpY_YrQR!N@g0;8+MRjv~J-dfm8T3XYFEvdvNl#*_iz-BUqLH)F+Sd*DeU`40%{%Fe7hnii6qAM#{MFDD6ut{=ySIApJPY#V;2c z3&}=1N9J2+oFMp>&EtoXy*BXKN(14!4Jux050Y~WuGZIXIV5&Av!_eT6S9WJ+uCXD z-$Xj$EUX`CMITScCS?&1?}ezGLwb%VFn?AGUz7aZlcv$?72=S(_tMQ;CkRhGD)+{r z!syci)LbXLNoL^A+36Hbh#2oucq^Zfvn=aD#JQV(O>$OKPONk;qu0H3zd-OUc>+Fj zmEX^2*tL)YT!vZob-XX|c0U& zZu@`9Qt;^#*YEx_;mualWB)G6qk1wQzNft}MtWc_J1pM1F>l{Odw)T4MC3`Znr;)J z(jQh*dQde_06@TDxz%i}fDcmO7=Lq{uVB2P@j-gHjjmc|vAFE#> z(y{@6$oHf+E{S6Hd(ltMJfGD&1u_f&>J($QbJY6n^Fi6EwD+p)DW;TDsekM|VH?g% zWjD1xF-p-^BHF;PKZ$r7=Kufz E0MU8lI{*Lx delta 2058 zcmV+l2=({X6!j1Z1KFaQXZuUF0FenMH5r@T;}2>450qo0k8XT=wG5f(M!P=fF$UED z(6=qhP6rSG0071M-g8F8tzd#Q6?hU~zeC=Citl1}l+QfA?=?;NkEZN;Uq0e?5e zyZWOg?=5-Q_ZVVqx*Z=zIgJm!%xP*_WFMOKJh~o1h*SHcr~q9mb*Y8Z_g;7Si+{I@&+abNm*+#N)Uz}!{r{5{Vh?zy7WGY<7nrF* zZWGN2qc-epGx`7e(2}h19NQcR#{`kgyaB^I_zYBzEYyr?-y;DpTM{B(+XIs~h$joZ z(WOv^ca$E4(fzW2TCi2=;h0?OVLi}S@@W>d;4N4q_kj~E^C8<(A3G>JNqK(BN z0`NAZneRWDy=jjuvU8!TYk!ll+Vd8x1dn(oZyBqEpI{?`>u&2jp0f)A32(fc-2wjc z$Owg!wJ4-|Bk@1Utj8lhNPlaJD0O0b@7?oO?=rihSn9VoT*I~NS+xVyv?ur@PI{g< zOr!&hHkao=v<&etW7j@3)#;)nWItC{c7Owp9D&N^T zWL&r=9L+k$f!aCbLtk;jopJij#?p zBg1N<&rW>r{>(sqrK_vcc+CrAmiGg|k3B-Ibw5RH7KRq12^1i!$A*n3{esG)t0?RzzI}*7g1;C*&#fmghvjiE7$tLvL}VK1-+?JThdS+saFssq;CznSX6Dya7>rtvIUcW(}>I zKnTo@i}AG&ps{!Ot@i{Yi|KE1Vzw4D`@4%P+%d16o^qxtSNm{fQRyn)#k017=$$xtyBL5n|;iQY9?%c2Cj^}8$ z(KruudFV;j&I#>?r^*(r<5ZDszV)7wBe{)*61j~17FTd*R*1qk6d2*tLdc+W?8`;bI?qr-bvI7`%2CxFXh~6XgcA zuvTq26}?gfiuC~)z_oZ8Vc*A!YyYYi<*oiYS5r}#vXxSmqiPk)ks!agay`UH7JJ2N z)~KmHVyU<$3M+QJbnw`gGiS-NA+^88h0)}>=6?*S6AiI|l&LIRD1L_fep6NiMwU&s z5~wB&GZzwIt@fhWIzAK4kn&X*;&CCl#G$-Iu15lTq!G#f8pyuX3r+o`r3oD&_6B1C zMc(Js_-_>=y#a%cZ|6|V^JWE3Hq)$0Axw?KmY&$ From 5cbc49d44fe1f9e7abdf2095b177b5490612cc26 Mon Sep 17 00:00:00 2001 From: gregoftheweb Date: Mon, 25 May 2026 10:41:28 -0700 Subject: [PATCH 5/8] docs: update README for KeePass provider and ds scaffold --- README.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 8 deletions(-) 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 From 7240c64c22043038a43f66246c031d4d3a078c63 Mon Sep 17 00:00:00 2001 From: gregoftheweb Date: Mon, 25 May 2026 11:45:09 -0700 Subject: [PATCH 6/8] Fixed duplicate password query --- internal/cli/init.go | 4 +-- internal/cli/scaffold.go | 10 ++++--- internal/cli/setup.go | 41 +++++++++++++++------------- internal/provider/keepass/adapter.go | 8 ++++++ 4 files changed, 37 insertions(+), 26 deletions(-) diff --git a/internal/cli/init.go b/internal/cli/init.go index 34280de..bd2d25e 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -15,13 +15,11 @@ func newInitCommand(s streams, opts *rootOptions) *cobra.Command { Use: "init", Short: "Generate .env.example from .env, with first-run provider setup", RunE: func(cmd *cobra.Command, args []string) error { - setupRan, cfg, err := ensureConfig(s, opts) + setupRan, cfg, _, err := ensureConfig(s, opts) if err != nil { - // Setup was cancelled or failed — exit cleanly. return nil } if setupRan { - // Config just written — tell the user what to do next and stop. fmt.Fprintln(s.stdout, "Run 'ds sync' to populate your .env file.") return nil } diff --git a/internal/cli/scaffold.go b/internal/cli/scaffold.go index 9661ce6..4c35660 100644 --- a/internal/cli/scaffold.go +++ b/internal/cli/scaffold.go @@ -23,9 +23,8 @@ 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, err := ensureConfig(s, opts) + _, cfg, setupPassword, err := ensureConfig(s, opts) if err != nil { - // Setup was cancelled or failed — exit cleanly. return nil } @@ -67,9 +66,12 @@ Only supported when provider is keepass.`, return nil } - // Build the KeePass client. ensurePassword will prompt once. + // 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 err := adapter.EnsurePassword(cmd.Context()); err != nil { + 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", diff --git a/internal/cli/setup.go b/internal/cli/setup.go index 1062369..7d90b37 100644 --- a/internal/cli/setup.go +++ b/internal/cli/setup.go @@ -15,39 +15,41 @@ import ( // 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 (true, nil) if setup ran and succeeded — the caller should reload -// config and continue. Returns (false, nil) if config already exists. -// Returns (false, err) if setup was cancelled or failed — the caller should -// stop cleanly without printing another error. -func ensureConfig(s streams, opts *rootOptions) (setupRan bool, cfg config.Config, err error) { +// 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 + 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 + return false, cfg, "", nil } - if err := runFirstTimeSetup(s, cfg); err != nil { + password, err = runFirstTimeSetup(s, cfg) + if err != nil { // Setup cancelled or failed — message already printed. - return false, config.Config{}, err + 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 false, config.Config{}, "", err } - return true, cfg, nil + 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. -func runFirstTimeSetup(s streams, cfg config.Config) error { +// 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.") @@ -63,7 +65,7 @@ func runFirstTimeSetup(s streams, cfg config.Config) error { 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") + return "", fmt.Errorf("setup cancelled") } var dbPath, groupName string @@ -74,12 +76,12 @@ func runFirstTimeSetup(s streams, cfg config.Config) error { 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") + 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") + return "", fmt.Errorf("setup cancelled") } fmt.Fprint(s.stdout, "Group name for env vars (default: dotenv): ") @@ -96,13 +98,14 @@ func runFirstTimeSetup(s streams, cfg config.Config) error { 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") + 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") + return "", fmt.Errorf("setup cancelled") } } @@ -117,11 +120,11 @@ func runFirstTimeSetup(s streams, cfg config.Config) error { } 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) + 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 nil + return password, nil } \ No newline at end of file diff --git a/internal/provider/keepass/adapter.go b/internal/provider/keepass/adapter.go index 40870b2..a7c970c 100644 --- a/internal/provider/keepass/adapter.go +++ b/internal/provider/keepass/adapter.go @@ -152,6 +152,14 @@ func (a *Adapter) ResolveMany(ctx context.Context, refs map[string]string) (map[ 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 { From 8699f98b197480de85b4dd6d98a639d253951286 Mon Sep 17 00:00:00 2001 From: gregoftheweb Date: Wed, 27 May 2026 06:41:25 -0700 Subject: [PATCH 7/8] chore: remove accidentally committed test artifacts from git tracking --- .env.example | 4 ---- .envsync.yaml | 5 ----- test-secrets.kdbx | Bin 2645 -> 0 bytes 3 files changed, 9 deletions(-) delete mode 100644 .env.example delete mode 100644 .envsync.yaml delete mode 100644 test-secrets.kdbx diff --git a/.env.example b/.env.example deleted file mode 100644 index c61ed60..0000000 --- a/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -# Database -DATABASE_URL= -JWT_SECRET= -PORT=3000 diff --git a/.envsync.yaml b/.envsync.yaml deleted file mode 100644 index 7b8671f..0000000 --- a/.envsync.yaml +++ /dev/null @@ -1,5 +0,0 @@ -provider: keepass -schema_file: .env.example -env_file: .env -keepass_database: test-secrets.kdbx -keepass_group: dotenv diff --git a/test-secrets.kdbx b/test-secrets.kdbx deleted file mode 100644 index a5c1db97e1676cb0156020e10f8b7bf4e6df550f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2645 zcmV-b3aa%3*`k_f`%AR}00aO65C8xGF~RcYzi~rQzE}kzYW!ON0|Wp70096100bZa z005jt82@`4SZqDOAZu7m_W(f3|8&$6b}w2I5vV;G4mk%90000+;#bzGGbOY0VlieV z_#!F`ivR!s00BY;0000aRaHqu5C8xG?_+J>j44D*k@u;j1LFz|1pxp607(b{000;O z00000000F60000@2mk;800004000001OWg508j(~000C4002S(0000}AOHXWhs5ot zT|%6i%5lkLkp%g?M`nVmCp7pE!cff}b!+Wb1OWg509FJ5000vJ000001ONa44GIkk z0K}l_u87(0w;MyKln?#N-m!04lWXlZ%E&~n(#&m@Poj_ML7=kmEAaW_S*S-TT5Q2o!JQ1@=GHgf;isgQFi%+}jn`NrLtzhA zN{8vlAZ`5fT=shra>wIGe4?4Ggy-G%d0S;qVW)t#P30|xk`W_g3K=naByC|ngS%I; znmtIO+Eyi%9eb8NjY)C#8`N#;Ib|FPR}MhIPG>+yK7G>cvgUXaW~=lRM15}ouAjq3 zX=3`LT5fa6)$rKXaku>|vJ|?^Jst<$C=~~Q?TBYR1=`CU)Z-3`*Os@2&RVpH$XLWj zSTT$V{|g_R1+LQTW_0bl?wKNFeEi8FLyjEYN^ZL}YM5G{(FT{~1NiOEe;tVuwS;jv zcY&Rj-kV%Zh{}c*GH4??{@@@A>9Gv!zs;;M)88t9pvQb`CTduf8n?1mpwV5a5{1hy#<Mh$DkaUyg2?h-a9iIS1OX zcqc<9Iy+Bm7a$V<`Oa?T{$I<*r&tbDj6UxTY!ZYVX+qfPPppGodBHENhr%2flV}iP zH?^V};tc^ANJsMIz_rG;&L!EE_j_y`!G-XNM7v?1Zoif7&?6b)v%!Q6h+(!_Pd`slP+tFixz~1CdE$jY!@}ilo6Ig^ira;OLS38dy7^h@#GrZ8 zC}O;da`!n@!57hu0!@qq;2_6y6HI z#lBJM-lXs#PvZm2ywL~TRm9KKhPTGPXxNY7;x{qQx`UvfzfE_3C!$+SeO!HuiDOP5T8A!Oz#e=h^+1u-M(f} zAeBCjLu$}xqp4&c0`NTTl)c&RBw3O_Q~5NLwt8PO5La1!PkA~J7QaLJT7oBo3vVDSyWP*$ZYCYsYYI)$*=Q<)P z8^dC$Y9I^Fp?5n#O`^WJ?P#<23>I3?*qHqn6%$<9<}BmsD=JP5!ay9;`)YAjFMK)% zJEkL_!lUSK02oP$NDKO4&#m?6(GVwFfiR#;a?~GI_7w9=+ho8JkVk9rX>!`Lk4uJl zY?{fD%IWREEWimA{-+X4ClrD8GyAS+eG?h@NQ^Ry!YWH%nkX|KTBE)OQ}ZKWfkk;H zCm2SE#T&}?GJ43%ZA<`)VyF~wdU5VMD+B3Ik=9n$?j?Yu!RK0uL+v?}PWwU`Uyk!f z=t65Tq?lbojWSy#b5jB)-SPxy6l~Ukr8Op?&g#xI_EjL*-Je}4pC_;3W_*`|+=T*8 zQrbm}jgu_#D`F%AsE7~y)LLJ_7BCK%eFd=2ivG*vEDZrpLs9cC=SCLW-q9wZ%5q5b zW^yAkSqQS>@KjL3-X-XBBWt}al1gS8(i?V=V6<-GB7sx*HD~Q&fqQWBwb_{cEF)Nz z|K%UqT#f2_$9aFQO#4@>)F+Sd*DeU`40%{%Fe7hnii6qAM#{MFDD6ut{=ySIApJPY z#V;2c3&}=1N9J2+oFMp>&EtoXy*BXKN(14!4Jux050Y~WuGZIXIV5&Av!_eT6S9WJ z+uCXD-$Xj$EUX`CMITScCS?&1?}ezGLwb%VFjfj*llyf5%}L&OM0j2{ePNhMN~Wm8u^+2<4rgITxWH-&uS6@YN{ z2p$lrhYcpdhLex3W3Gc-uRtz(YHig9SEQs+wnnmN9B0+Y&uSx@*aG4MG9SLDy)Q<3U@ki>-nubw-$HwTL2^XoNwAu3 z6QR-{R#JLUHBSIQz+t)7Y^;C}Qs5YKo3CKhS(o3p8tre0=&xM z_MX;3VH&I$%(g_b{Rg_%!LC4-%xw(+TC@QK3Y7b7`i`Ma1dYjSb|0%> zA=0t|f5`WwHZF-`_IuG!&OD#hI|VWe|LPQDw{z6`?ejs|skHa1>?x*{Q>pAcVH?g% zWjD1xF-p-^BHF;PKZ$r Date: Wed, 27 May 2026 06:49:34 -0700 Subject: [PATCH 8/8] fix: address PR review feedback from mikeboiko --- .env.example | 4 ++++ .envsync.yaml | 5 +++++ go.mod | 2 +- internal/cli/doctor.go | 2 +- internal/cli/init.go | 2 +- internal/cli/push.go | 8 +++++++- internal/cli/root.go | 2 +- internal/cli/scaffold.go | 2 +- internal/provider/keepass/adapter.go | 2 ++ 9 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 .env.example create mode 100644 .envsync.yaml 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/go.mod b/go.mod index 2422a0f..8fcb2e4 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ 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 ) @@ -11,5 +12,4 @@ 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 - golang.org/x/term v0.43.0 // indirect ) diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index e5b3c3f..2db6c7b 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -28,4 +28,4 @@ func newDoctorCommand(s streams, opts *rootOptions) *cobra.Command { return nil }, } -} \ No newline at end of file +} diff --git a/internal/cli/init.go b/internal/cli/init.go index bd2d25e..1845719 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -17,7 +17,7 @@ func newInitCommand(s streams, opts *rootOptions) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { setupRan, cfg, _, err := ensureConfig(s, opts) if err != nil { - return nil + return err } if setupRan { fmt.Fprintln(s.stdout, "Run 'ds sync' to populate your .env 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 e099b58..7d925df 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -104,4 +104,4 @@ func providerFor(cfg config.Config) provider.Provider { func pushProviderFor(cfg config.Config) provider.PushProvider { return pushProviderFactory(cfg) -} \ No newline at end of file +} diff --git a/internal/cli/scaffold.go b/internal/cli/scaffold.go index 4c35660..e878f64 100644 --- a/internal/cli/scaffold.go +++ b/internal/cli/scaffold.go @@ -25,7 +25,7 @@ Only supported when provider is keepass.`, RunE: func(cmd *cobra.Command, args []string) error { _, cfg, setupPassword, err := ensureConfig(s, opts) if err != nil { - return nil + return err } // scaffold is KeePass-only. diff --git a/internal/provider/keepass/adapter.go b/internal/provider/keepass/adapter.go index a7c970c..b124393 100644 --- a/internal/provider/keepass/adapter.go +++ b/internal/provider/keepass/adapter.go @@ -175,6 +175,8 @@ func (a *Adapter) CreateEntry(ctx context.Context, key string) error { } 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. //