Skip to content

Architecture

Technical architecture and design decisions.

Design Principles

  • Interface-based DI: All external dependencies behind interfaces
  • Testability: Mocks for all interfaces
  • Separation of concerns: CLI, business logic, and infrastructure are separate

Core Interfaces

Storage

type Storage interface {
    Upload(ctx context.Context, path string, r io.Reader) error
    Download(ctx context.Context, path string) (io.ReadCloser, error)
    List(ctx context.Context, prefix string) ([]string, error)
    Delete(ctx context.Context, path string) error
    Exists(ctx context.Context, path string) (bool, error)
}

Encrypter

type Encrypter interface {
    Encrypt(plaintext []byte) ([]byte, error)
    Decrypt(ciphertext []byte) ([]byte, error)
}

Repository

type Repository interface {
    Init() error
    Add(paths ...string) error
    Commit(message string) (string, error)
    Log(n int, includeFiles bool) ([]Commit, error)
    Checkout(ref string) error
    ListFiles() ([]string, error)
    ReadFile(path, ref string) ([]byte, error)
    WriteFile(path string, content []byte) error
    RemoveFile(path string) error
    Head() (string, error)
    PackAll(w io.Writer) error
    UnpackAll(r io.Reader) error
    GetAllRefs() (map[string]string, error)
    SetRef(name, hash string) error
    DeleteRef(name string) error
}

Package Dependencies

cmd/envsecrets
    └── internal/cli
            ├── internal/config
            ├── internal/sync
            │       ├── internal/storage
            │       ├── internal/crypto
            │       ├── internal/git
            │       └── internal/cache
            ├── internal/project
            └── internal/ui

Data Flow

Push

  1. CLI parses flags and loads config
  2. Project discovery finds repo identity and env files
  3. Sync from GCS: download packfile + refs, restore full git history locally
  4. Fast-forward local branch to remote HEAD if behind
  5. For each file:
  6. Read plaintext from project directory
  7. Encrypt with age
  8. Write encrypted file to cache
  9. Commit changes to cache git repo
  10. Conflict check: verify remote HEAD hasn't changed since step 3
  11. Sync to GCS: create packfile of all objects + refs, upload to GCS

Pull

  1. CLI parses flags and loads config
  2. Project discovery finds repo identity
  3. Sync from GCS: download packfile + refs, restore full git history locally
  4. Checkout requested ref (or HEAD) to populate working tree
  5. For each file in cache:
  6. Read encrypted file
  7. Decrypt with age
  8. Write plaintext to project directory

Cache Structure

~/.envsecrets/
├── config.yaml
└── cache/
    └── {owner}/
        └── {repo}/
            ├── .git/          # Full git history (restored from packfile)
            ├── .env.age       # Encrypted files (working tree, populated by checkout)
            └── .env.local.age

The cache is a git repository containing only encrypted files. Full git history is synced between machines via packfiles stored in GCS.

GCS Storage Layout

{owner}/{repo}/objects.pack   # Packfile containing all git objects
{owner}/{repo}/refs           # Text file: refname SP hash LF
{owner}/{repo}/HEAD           # Current HEAD commit hash

Every sync downloads the packfile and restores full git history locally. This enables log, diff, and revert to work correctly across machines with shared commit history.

Error Handling

Sentinel errors in internal/domain/errors.go map to exit codes:

Error Exit Code Description
ErrNotConfigured 1 Missing configuration
ErrNotInRepo 2 Not in a git repository
ErrNoEnvFiles 3 No .envsecrets file found
ErrConflict 4 Local/remote conflict
ErrDecryptFailed 5 Decryption failed
ErrUploadFailed 6 GCS upload failed
ErrDownloadFailed 7 GCS download failed

Configuration Loading

  1. Check --config flag
  2. Check ENVSECRETS_CONFIG environment variable
  3. Use default ~/.envsecrets/config.yaml
  4. Validate required fields
  5. Resolve passphrase (env, command, or prompt)