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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Database
DATABASE_URL=

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Same concern — this appears to be a demo/test artifact rather than something that belongs in the project root. The project generates .env.example via ds init; committing a sample one here may confuse contributors or conflict with the actual project schema.

Please remove, or move to testdata/ if needed for tests.

JWT_SECRET=
PORT=3000
5 changes: 5 additions & 0 deletions .envsync.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
provider: keepass
schema_file: .env.example
env_file: .env
keepass_database: test-secrets.kdbx

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This config file should not be committed to the project root — it hardcodes provider: keepass and points at test-secrets.kdbx, which would override the config for every contributor who clones the repo.

If you need this for test fixtures, place it under testdata/. Otherwise please remove it.

keepass_group: dotenv
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ Thumbs.db
*.swp
.vscode/
.idea/
*.kdbx
64 changes: 56 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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`.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
module dotenv-sync

go 1.22
go 1.25.0

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This bumps the minimum Go version from 1.22 to 1.25.0 — a significant breaking change for anyone on an older Go.

golang.org/x/term v0.43.0 and golang.org/x/sys v0.44.0 both support Go 1.22+, so this bump appears unnecessary. Please revert to go 1.22.

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
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}
Expand Down
11 changes: 8 additions & 3 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -44,4 +49,4 @@ func newInitCommand(s streams, opts *rootOptions) *cobra.Command {
}
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview without writing")
return cmd
}
}
8 changes: 7 additions & 1 deletion internal/cli/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
11 changes: 10 additions & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

ds push with provider: keepass will silently use Bitwarden here, which is confusing and potentially dangerous — the user thinks they're pushing to their configured provider but they're actually interacting with a different one.

Please add a guard in the push command (or here) that returns a clear error like "push is not supported for the keepass provider" when cfg.Provider == "keepass".

return bitwarden.NewAdapter(cfg)
}

Expand Down Expand Up @@ -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
}

Expand Down
104 changes: 104 additions & 0 deletions internal/cli/scaffold.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading