Skip to content
Open
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
3 changes: 3 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ before:

builds:
- main: ./cmd/envmagic
ldflags:
- -s -w
- -X=main.version={{.Version}}
env:
- CGO_ENABLED=0
goos:
Expand Down
12 changes: 8 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
BINARY := envmagic
GOBIN := $(shell go env GOPATH)/bin

# Injected at link time; falls back to "dev" when not set (e.g. plain go run).
VERSION_LD := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GO_LD_FLAGS := -s -w -X main.version=$(VERSION_LD)

.PHONY: build install lint test version tag release

build:
go build -o $(BINARY) ./cmd/envmagic
go build -ldflags "$(GO_LD_FLAGS)" -o $(BINARY) ./cmd/envmagic

install:
go install ./cmd/envmagic
go install -ldflags "$(GO_LD_FLAGS)" ./cmd/envmagic

lint:
gofumpt -w .
Expand All @@ -18,7 +22,7 @@ test: lint
go test ./...

version:
@VERSION=$$(go run ./cmd/envmagic --version | awk '{print $$NF}'); \
@VERSION=$$(go run -ldflags "$(GO_LD_FLAGS)" ./cmd/envmagic --version | awk '{print $$NF}'); \
if git tag | grep -qx "$$VERSION"; then \
echo "Error: version $$VERSION already exists as a git tag — bump the version before committing"; \
exit 1; \
Expand All @@ -27,7 +31,7 @@ version:
fi

tag: version
@VERSION=$$(go run ./cmd/envmagic --version | awk '{print $$NF}'); \
@VERSION=$$(go run -ldflags "$(GO_LD_FLAGS)" ./cmd/envmagic --version | awk '{print $$NF}'); \
git tag "$$VERSION" && echo "Tagged $$VERSION"
Comment on lines 24 to 35

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 make tag / make release are broken by the switch to git describe

The version guard and tag target both derive VERSION from VERSION_LD = git describe --tags --always --dirty. This creates a catch-22 for every release:

  • Before tagginggit describe returns something like v0.4.0-3-gabcdef (or v0.4.0-3-gabcdef-dirty). The version guard passes ("tag not yet created"), and make tag then creates a garbage tag v0.4.0-3-gabcdef instead of the intended v0.5.0.
  • After tagging — if the developer manually creates git tag v0.5.0 first, git describe returns v0.5.0, but the version guard immediately fails with "Error: version v0.5.0 already exists", blocking make tag entirely.

The old const version = "v0.4.0" approach worked because the developer bumped that constant to v0.5.0 before running make tag, and git describe was never involved. With git describe as the sole version source, there is no clean state where make tag / make release can produce a correct semver tag. GoReleaser itself is unaffected (it uses {{.Version}} from an existing tag), but the local make release workflow is currently unusable.

Fix in Claude Code Fix in Cursor


release: tag
Expand Down
37 changes: 28 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ envmagic rm api_key
# Import / export .env files
envmagic import .env
envmagic -n staging export staging.env

# Non-interactive / CI: create .envmagic without a prompt (set/import only)
envmagic --yes api_key 'sk-abc123'
envmagic import --yes .env
# or: ENVMAGIC_NONINTERACTIVE=1 envmagic import .env
```

Variable names are uppercased automatically: `envmagic api_key …` stores
Expand Down Expand Up @@ -112,7 +117,8 @@ writing, so a truncated backup is rejected before it overwrites anything.
## How it works

- **Store.** Each project gets a `.envmagic` SQLite file. `set` looks for one
in the current directory and offers to create it; `get`/`list`/`rm` walk up
in the current directory and offers to create it (use `--yes` or
`ENVMAGIC_NONINTERACTIVE=1` to create without a prompt); `get`/`list`/`rm` walk up
the directory tree to find the nearest one (like `.git`).
- **Encryption.** Values are sealed with AES-256-GCM. Names and namespaces
are stored in plaintext (so `list` works without the key); only values are
Expand Down Expand Up @@ -171,14 +177,14 @@ import (
)

func main() {
c, err := envmagic.Open("/path/to/project/.envmagic")
c, err := envmagic.OpenWithPath("/path/to/project/.envmagic")
if err != nil {
log.Fatal(err)
}
defer c.Close()

// Decrypts every variable in the namespace and calls os.Setenv for each.
if err := c.Load("default"); err != nil {
if _, err := c.Load(envmagic.DefaultNamespace); err != nil {
log.Fatal(err)
}

Expand All @@ -190,7 +196,12 @@ func main() {
### Read a single variable

```go
val, err := c.Get("default", "API_KEY")
import (
"errors"
"log"
)

val, err := c.Get(envmagic.DefaultNamespace, "API_KEY")
if errors.Is(err, envmagic.ErrNotFound) {
log.Fatal("API_KEY is not set")
}
Expand All @@ -202,18 +213,26 @@ if err != nil {
### API reference

```go
// Open opens (or creates) the store at storePath, loading the key from
const DefaultNamespace = "default"

// Open opens (or creates) .envmagic in the current working directory.
func Open() (*Client, error)

// OpenWithPath opens (or creates) the store at storePath, loading the key from
// $XDG_CONFIG_HOME/envmagic/key (generated on first use).
func Open(storePath string) (*Client, error)
func OpenWithPath(storePath string) (*Client, error)

// OpenWithKeyAndPath opens storePath using the key file at keyPath.
func OpenWithKeyAndPath(keyPath, storePath string) (*Client, error)

// Close closes the underlying store.
func (c *Client) Close() error

// Load decrypts all variables in namespace and sets them via os.Setenv.
func (c *Client) Load(namespace string) error
// Load decrypts all variables in namespace, sets them via os.Setenv, and returns the names loaded.
func (c *Client) Load(namespace string) ([]string, error)

// Get returns the decrypted value for namespace/name.
// Returns ErrNotFound if the entry does not exist.
// Use errors.Is(err, ErrNotFound) when the entry does not exist.
func (c *Client) Get(namespace, name string) (string, error)

var ErrNotFound = errors.New("not found")
Expand Down
42 changes: 25 additions & 17 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,24 @@ package envmagic
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"

"github.com/peteraba/envmagic/internal"
)

const DEFAULT_NAMESPACE = "default"
// DefaultNamespace is the namespace used when none is specified on the CLI.
const DefaultNamespace = "default"

// ErrNotFound is returned by Get when the requested variable does not exist.
var ErrNotFound = errors.New("not found")

// Client holds an open store and its encryption key.
// Obtain one via Open.
type Client struct {
s *internal.Store
key []byte
s *internal.Store
key []byte
keyCreated bool
}

// OpenWithKeyAndPath opens a store at storePath using the key from the specified key path.
Expand All @@ -37,52 +39,58 @@ func OpenWithKeyAndPath(keyPath, storePath string) (*Client, error) {
return &Client{s: s, key: key}, nil
}

// OpenWithPath opens (or creates) a store at storePath using the key from the default
// OpenWithPath opens (or creates) the SQLite store at storePath using the key from the default
// key path (~/.config/envmagic/key), generating a new key if none exists.
// If the store file does not exist, it is created on open.
func OpenWithPath(storePath string) (*Client, error) {
s, err := internal.OpenStore(storePath)
if err != nil {
return nil, fmt.Errorf("failed to open store, store path: %s, error: %w", storePath, err)
}

key, err := internal.LoadOrCreateKey()
key, created, err := internal.LoadOrCreateKey()
if err != nil {
_ = s.Close()
return nil, fmt.Errorf("failed to load or create key, store path: %s, error: %w", storePath, err)
}

return &Client{s: s, key: key}, nil
return &Client{s: s, key: key, keyCreated: created}, nil
}

// Open opens (or creates) a store at the default store path using the key from the default
// key path (~/.config/envmagic/key), generating a new key if none exists.
// Open opens (or creates) the store at .envmagic in the current working directory,
// using the key from the default key path (~/.config/envmagic/key), generating a new key if none exists.
func Open() (*Client, error) {
dir, err := os.Getwd()
if err != nil {
slog.Error("Error getting working directory", "error", err, "dir", dir)
os.Exit(1)
return nil, fmt.Errorf("get working directory: %w", err)
}

return OpenWithPath(dir + "/.envmagic")
return OpenWithPath(filepath.Join(dir, ".envmagic"))
}

// Close closes the underlying store.
func (c *Client) Close() error {
return c.s.Close()
}

// KeyCreated reports whether Open or OpenWithPath generated a new default key file
// (~/.config/envmagic/key) because none existed. Callers should warn users to back
// up the key when this is true.
func (c *Client) KeyCreated() bool {
return c.keyCreated
}

// Get retrieves and decrypts the value for namespace/name.
// Returns ErrNotFound if the entry does not exist.
// Returns ErrNotFound if the entry does not exist; use errors.Is(err, ErrNotFound).
func (c *Client) Get(namespace, name string) (string, error) {
enc, err := c.s.Get(namespace, name)
if err != nil {
if errors.Is(err, internal.ErrEntryNotFound) {
return "", ErrNotFound
}
return "", fmt.Errorf("failed to get entry, namespace: %s, name: %s, error: %w", namespace, name, err)
}

if enc == nil {
return "", fmt.Errorf("entry not found: %w", ErrNotFound)
}

plain, err := internal.Decrypt(c.key, enc)
if err != nil {
return "", fmt.Errorf("failed to decrypt entry: %w", err)
Expand Down
73 changes: 73 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package envmagic_test

import (
"errors"
"path/filepath"
"testing"

"github.com/peteraba/envmagic"
)

func TestOpenWithPath_KeyCreated(t *testing.T) {
xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg)
storePath := filepath.Join(t.TempDir(), ".envmagic")

c1, err := envmagic.OpenWithPath(storePath)
if err != nil {
t.Fatal(err)
}
_ = c1.Close()
if !c1.KeyCreated() {
t.Fatal("first open: want KeyCreated true (new key file)")
}

c2, err := envmagic.OpenWithPath(storePath)
if err != nil {
t.Fatal(err)
}
_ = c2.Close()
if c2.KeyCreated() {
t.Fatal("second open: want KeyCreated false")
}
}

func TestClient_Get_ErrNotFound(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
storePath := filepath.Join(t.TempDir(), ".envmagic")

c, err := envmagic.OpenWithPath(storePath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = c.Close() })

_, err = c.Get(envmagic.DefaultNamespace, "MISSING_VAR")
if !errors.Is(err, envmagic.ErrNotFound) {
t.Fatalf("Get: want ErrNotFound, got %v", err)
}
}

func TestOpen_usesDotEnvmagicInCwd(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
store := filepath.Join(dir, ".envmagic")

c0, err := envmagic.OpenWithPath(store)
if err != nil {
t.Fatal(err)
}
_ = c0.Close()

t.Chdir(dir)

c, err := envmagic.Open()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = c.Close() })

if _, err := c.Get(envmagic.DefaultNamespace, "ANY"); !errors.Is(err, envmagic.ErrNotFound) {
t.Fatalf("Open cwd store: Get missing: %v", err)
}
}
Loading