42 Commits

Author SHA1 Message Date
2cb6067a3a feat(odin): ported edit-config command to odin. 2026-06-11 21:26:59 -04:00
3668df57d1 feat(odin): ported restore command to odin. 2026-06-11 21:25:11 -04:00
d2127e4780 feat(odin): Ported remove command. 2026-06-11 21:21:59 -04:00
cb7db96781 feat(odin): Added long text and --help flags. 2026-06-11 21:17:52 -04:00
c92155a17b feat(odin): ported backup command. 2026-06-11 21:14:11 -04:00
b1d2416182 feat(odin): ported list command. 2026-06-11 21:05:39 -04:00
40f0b3c36d feat(odin): ported deps command, added utilities (features, tty, table). 2026-06-11 21:05:33 -04:00
d84e43d044 odin: scaffold project with CLI parser, version command, Go fallback 2026-06-11 20:34:53 -04:00
28f96df4c0 feat: Started odin setup. 2026-06-11 20:08:27 -04:00
Spencer Brower
c6d0308842 chore(main): release 0.2.1 2026-01-12 14:42:05 -05:00
cf363abc4d fix: Added add as an alias for backup. 2026-01-12 14:40:42 -05:00
d3dbf2a05a build(nix): Updated flake. 2026-01-12 14:40:38 -05:00
5a9038df87 docs: Added WINDOWS.md for when I add windows support. 2025-11-11 11:02:52 -05:00
Spencer Brower
06e0d8067c chore(main): release 0.2.0 2025-11-10 10:20:56 -05:00
4db0a4d33d feat(sync): envr can now detect if directories have moved. 2025-11-10 10:13:18 -05:00
638751fb48 refactor: Restore and Sync now share code through the sync function. 2025-11-10 10:13:18 -05:00
39dc586d3c refactor: Swapped position of Sync and Restore for better readability. 2025-11-10 10:13:18 -05:00
5eaf691dcd refactor(db): Removed the need to pass CloseMode to Db.Close. 2025-11-07 14:43:30 -05:00
1a3172dc6f docs: Updated comments on SshKeyPair. 2025-11-07 12:19:29 -05:00
66b113049b refactor: Removed TODOs. 2025-11-07 11:51:11 -05:00
169653d756 feat(init): Added a --force flag for overwriting an existing config. 2025-11-07 11:48:36 -05:00
8074f7ae6d feat(sync): Now checks files for mismatched hashes before replacing. 2025-11-07 11:38:58 -05:00
9a729e6e2a docs: Removed old TODO. 2025-11-07 11:16:27 -05:00
0fef74a9bb refactor!: Dir is no longer stored in the database.
BREAKING CHANGE: Dir is now derived from Path rather than stored in the
DB. Your DB will need to be updated.
2025-11-07 11:12:29 -05:00
38a6776b31 chore: remotes now get unmarshalled from the database. 2025-11-07 10:54:54 -05:00
15be62b5a2 feat(config): The default config now filters out more junk.
This includes `.envrc` files, `.local/`, `node_modules`, and `vendor`.
2025-11-07 10:44:55 -05:00
f43705cd53 feat(scan)!: Added support for multiple exports.
BREAKING CHANGE: The config value `scan.Exclude` is now a list rather than a string.
2025-11-07 10:41:46 -05:00
cbd74f387e feat: Added new check command. 2025-11-06 17:35:11 -05:00
c9c34ce771 refactor(check)!: Renamed the check command to deps. 2025-11-06 17:10:53 -05:00
17ce49cd2d fix(check): fd now correctly gets marked as found. 2025-11-06 17:06:26 -05:00
af0a9f9c4c docs: Added todos. 2025-11-05 18:31:39 -05:00
4273fa5895 feat!: Multiple scan includes are now supported.
BREAKING CHANGE: The config value `scan.Include` is now a list rather than
a string.

Release-As: 0.2.0
2025-11-05 18:29:19 -05:00
bb3c0cdeee chore: Updated nix version. 2025-11-05 18:09:58 -05:00
Spencer Brower
df5fdeee67 chore(main): release 0.1.1 2025-11-05 18:08:40 -05:00
42796ec77b feat(sync): Results are now displayed in a table.
Release-As: 0.1.1
2025-11-05 17:49:22 -05:00
8a349dd760 build: Added Makefile for cross-platform builds. 2025-11-05 17:28:59 -05:00
7a49858a58 docs: Updated TODOs. 2025-11-05 16:35:07 -05:00
9ab72a25fa fix(sync): Fixed an issue where deleted folders would be restored. 2025-11-05 15:43:32 -05:00
35519550ed build: Added release-please. 2025-11-05 15:34:06 -05:00
Spencer Brower
7a60c673be build: Updated go version for github action. 2025-11-05 15:34:03 -05:00
Spencer Brower
f8dc85a8b7 build: Created github actions build. 2025-11-04 14:06:13 -05:00
5310803c8b docs: Updated README.md 2025-11-03 18:29:23 -05:00
42 changed files with 2198 additions and 242 deletions

28
.github/workflows/go.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24.6'
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...

25
.github/workflows/release-please.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
on:
push:
branches:
- main
permissions:
contents: write
issues: write
pull-requests: write
name: release-please
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
# this assumes that you have created a personal access token
# (PAT) and configured it as a GitHub action secret named
# `MY_RELEASE_PLEASE_TOKEN` (this secret name is not important).
token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }}
# this is a built-in strategy in release-please, see "Action Inputs"
# for more options
release-type: go

2
.gitignore vendored
View File

@@ -5,5 +5,7 @@
man man
# build artifacts # build artifacts
builds
envr envr
envr-go
result result

51
CHANGELOG.md Normal file
View File

@@ -0,0 +1,51 @@
# Changelog
## [0.2.1](https://github.com/sbrow/envr/compare/v0.2.0...v0.2.1) (2026-01-12)
### Bug Fixes
* Added `add` as an alias for backup. ([cf363ab](https://github.com/sbrow/envr/commit/cf363abc4d8cec208d23c6acedbb7e0dd6900332))
## [0.2.0](https://github.com/sbrow/envr/compare/v0.1.1...v0.2.0) (2025-11-10)
### ⚠ BREAKING CHANGES
* Dir is now derived from Path rather than stored in the DB. Your DB will need to be updated.
* **scan:** The config value `scan.Exclude` is now a list rather than a string.
* **check:** Renamed the `check` command to `deps`.
* The config value `scan.Include` is now a list rather than a string.
### Features
* Added new `check` command. ([cbd74f3](https://github.com/sbrow/envr/commit/cbd74f387e2e330b2557d07dd82ba05cc91300ac))
* **config:** The default config now filters out more junk. ([15be62b](https://github.com/sbrow/envr/commit/15be62b5a2a5a735b90b074497d645c5a2cfced8))
* **init:** Added a `--force` flag for overwriting an existing config. ([169653d](https://github.com/sbrow/envr/commit/169653d7566f63730fb9da80a18330a566223be9))
* Multiple scan includes are now supported. ([4273fa5](https://github.com/sbrow/envr/commit/4273fa58956d8736271a0af66202dca481126fe4))
* **scan:** Added support for multiple exports. ([f43705c](https://github.com/sbrow/envr/commit/f43705cd53c6d87aef1f69df4e474441f25c1dc7))
* **sync:** envr can now detect if directories have moved. ([4db0a4d](https://github.com/sbrow/envr/commit/4db0a4d33d2b6a79d13b36a8e8631f895e8fef8d))
* **sync:** Now checks files for mismatched hashes before replacing. ([8074f7a](https://github.com/sbrow/envr/commit/8074f7ae6dfa54e931a198257f3f8e6d0cfe353a))
### Bug Fixes
* **check:** `fd` now correctly gets marked as found. ([17ce49c](https://github.com/sbrow/envr/commit/17ce49cd2d33942282c6f54ce819ac25978f6b7c))
### Code Refactoring
* **check:** Renamed the `check` command to `deps`. ([c9c34ce](https://github.com/sbrow/envr/commit/c9c34ce771653da214635f1df1fef1f23265c552))
* Dir is no longer stored in the database. ([0fef74a](https://github.com/sbrow/envr/commit/0fef74a9bba0fbf3c34b66c2095955e6eee7047b))
## [0.1.1](https://github.com/sbrow/envr/compare/v0.1.0...v0.1.1) (2025-11-05)
### Features
* **sync:** Results are now displayed in a table. ([42796ec](https://github.com/sbrow/envr/commit/42796ec77b1817e1b9f09068d76a7b6e30da246b))
### Bug Fixes
* **sync:** Fixed an issue where deleted folders would be restored. ([9ab72a2](https://github.com/sbrow/envr/commit/9ab72a25faf1af0eedb2f4574166c6ee47450ebb))

92
Makefile Normal file
View File

@@ -0,0 +1,92 @@
# Makefile for envr - Environment file manager
# Builds release artifacts for GitHub releases
APP_NAME := envr
VERSION := $(shell grep 'version = ' flake.nix | head -1 | sed 's/.*version = "\(.*\)";/\1/')
BUILD_DIR := builds
LDFLAGS := -X github.com/sbrow/envr/cmd.version=v$(VERSION) -s -w
# Binary names
LINUX_AMD64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64
LINUX_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64
DARWIN_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64
.PHONY: all clean cleanall build-linux build-darwin compress release help
# Default target
all: release clean
# Create build directory
$(BUILD_DIR):
@mkdir -p $(BUILD_DIR)
# Build Linux AMD64
$(LINUX_AMD64_BIN): $(BUILD_DIR)
@echo "Building for Linux AMD64..."
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(LINUX_AMD64_BIN) .
@echo "Built $(LINUX_AMD64_BIN)"
# Build Linux ARM64
$(LINUX_ARM64_BIN): $(BUILD_DIR)
@echo "Building for Linux ARM64..."
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(LINUX_ARM64_BIN) .
@echo "Built $(LINUX_ARM64_BIN)"
# Build Darwin ARM64 (Mac)
$(DARWIN_ARM64_BIN): $(BUILD_DIR)
@echo "Building for Darwin ARM64..."
GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(DARWIN_ARM64_BIN) .
@echo "Built $(DARWIN_ARM64_BIN)"
# Build all binaries
build-linux: $(LINUX_AMD64_BIN) $(LINUX_ARM64_BIN)
build-darwin: $(DARWIN_ARM64_BIN)
# Compress Linux artifacts with gzip
$(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64.tar.gz: $(LINUX_AMD64_BIN)
@echo "Compressing Linux AMD64 artifact..."
cd $(BUILD_DIR) && tar -czf $(APP_NAME)-$(VERSION)-linux-amd64.tar.gz --transform 's|.*|$(APP_NAME)|' $(shell basename $(LINUX_AMD64_BIN))
$(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64.tar.gz: $(LINUX_ARM64_BIN)
@echo "Compressing Linux ARM64 artifact..."
cd $(BUILD_DIR) && tar -czf $(APP_NAME)-$(VERSION)-linux-arm64.tar.gz --transform 's|.*|$(APP_NAME)|' $(shell basename $(LINUX_ARM64_BIN))
# Compress Darwin artifacts with zip
$(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64.zip: $(DARWIN_ARM64_BIN)
@echo "Compressing Darwin ARM64 artifact..."
cd $(BUILD_DIR) && cp $(shell basename $(DARWIN_ARM64_BIN)) $(APP_NAME) && zip $(APP_NAME)-$(VERSION)-darwin-arm64.zip $(APP_NAME) && rm $(APP_NAME)
# Compress all artifacts
compress: $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64.tar.gz \
$(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64.tar.gz \
$(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64.zip
# Build and compress all release artifacts
release: build-linux build-darwin compress
@echo "Release artifacts created:"
@ls -la $(BUILD_DIR)/*.tar.gz $(BUILD_DIR)/*.zip 2>/dev/null || echo "No compressed artifacts found"
# Clean binary files only
clean:
@echo "Cleaning binary files..."
@rm -f $(LINUX_AMD64_BIN) $(LINUX_ARM64_BIN) $(DARWIN_ARM64_BIN)
# Clean everything in build directory
cleanall:
@echo "Cleaning build directory..."
@rm -rf $(BUILD_DIR)
# Show available targets
help:
@echo "Available targets:"
@echo " all - Build all release artifacts (default)"
@echo " release - Build and compress all release artifacts"
@echo " build-linux - Build Linux binaries only"
@echo " build-darwin - Build Darwin binaries only"
@echo " compress - Compress all built binaries"
@echo " clean - Remove binary files only"
@echo " cleanall - Remove entire build directory"
@echo " help - Show this help message"
@echo ""
@echo "Release artifacts will be created in $(BUILD_DIR)/"
@echo "Version: $(VERSION)"

View File

@@ -19,10 +19,11 @@ be run on a cron.
- 🔍 **Smart Scanning**: Automatically discover and import `.env` files in your - 🔍 **Smart Scanning**: Automatically discover and import `.env` files in your
home directory. home directory.
-**Interactive CLI**: User-friendly prompts for file selection and management. -**Interactive CLI**: User-friendly prompts for file selection and management.
- 🗂️ **Rename Detection**: Automatically finds and updates renamed/moved
repositories.
## TODOS ## TODOS
- [x] Rename Detection: automatically update moved files.
- [ ] 🗂️ **Rename Detection**: Automatically handle renamed repositories.
- [ ] Allow use of keys from `ssh-agent` - [ ] Allow use of keys from `ssh-agent`
- [x] Allow configuration of ssh key. - [x] Allow configuration of ssh key.
- [x] Allow multiple ssh keys. - [x] Allow multiple ssh keys.
@@ -88,8 +89,8 @@ The configuration file is created during initialization:
{ {
"keys": [ "keys": [
{ {
"private": "/home/spencer/.ssh/id_ed25519", "private": "/home/ubuntu/.ssh/id_ed25519",
"public": "/home/spencer/.ssh/id_ed25519.pub" "public": "/home/ubuntu/.ssh/id_ed25519.pub"
} }
], ],
"scan": { "scan": {

92
WINDOWS.md Normal file
View File

@@ -0,0 +1,92 @@
# Windows Compatibility Guide
This document outlines Windows compatibility issues and solutions for the envr project.
## Critical Issues
### 1. Path Handling Bug (MUST FIX)
**File:** `app/env_file.go:209`
**Issue:** Uses `path.Join` instead of `filepath.Join`, which won't work correctly on Windows due to different path separators.
**Current code:**
```go
f.Path = path.Join(newDir, path.Base(f.Path))
```
**Fixed code:**
```go
f.Path = filepath.Join(newDir, filepath.Base(f.Path))
```
## External Dependencies
The application relies on external tools that need to be installed separately on Windows:
### Required Tools
1. **fd** - Fast file finder
- Install via: `winget install sharkdp.fd` or `choco install fd`
- Alternative: `scoop install fd`
2. **git** - Version control system
- Install via: `winget install Git.Git` or download from git-scm.com
- Usually already available on most development machines
## Minor Compatibility Notes
### File Permissions
- Unix file permissions (`0755`, `0644`) are used throughout the codebase
- These are safely ignored on Windows - no changes needed
### Editor Configuration
**File:** `cmd/edit_config.go:20-24`
**Issue:** Relies on `$EDITOR` environment variable which is less common on Windows.
**Current behavior:** Fails if `$EDITOR` is not set
**Recommended improvement:** Add fallback detection for Windows editors:
```go
editor := os.Getenv("EDITOR")
if editor == "" {
if runtime.GOOS == "windows" {
editor = "notepad.exe" // or "code.exe" for VS Code
} else {
fmt.Println("Error: $EDITOR environment variable is not set")
return
}
}
```
## Installation Instructions for Windows
1. Install required dependencies:
```powershell
winget install sharkdp.fd
winget install Git.Git
```
2. Fix the path handling bug in `app/env_file.go:209`
3. Build and run as normal:
```powershell
go build
.\envr.exe init
```
## Testing on Windows
After applying the critical path fix, the core functionality should work correctly on Windows. The application has been designed with cross-platform compatibility in mind, using:
- `filepath` package for path operations (mostly)
- `os.UserHomeDir()` for home directory detection
- Standard Go file operations
## Summary
- **1 critical bug** must be fixed for Windows compatibility
- **2 external tools** need to be installed
- **1 minor enhancement** recommended for better Windows UX
- Overall architecture is Windows-compatible

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -18,15 +19,17 @@ type Config struct {
ScanConfig scanConfig `json:"scan"` ScanConfig scanConfig `json:"scan"`
} }
// Used by age to encrypt and decrypt the database.
type SshKeyPair struct { type SshKeyPair struct {
Private string `json:"private"` // Path to the private key file Private string `json:"private"` // Path to the private key file
Public string `json:"public"` // Path to the public key file Public string `json:"public"` // Path to the public key file
} }
type scanConfig struct { type scanConfig struct {
// TODO: Support multiple matchers
Matcher string `json:"matcher"` Matcher string `json:"matcher"`
Exclude string `json:"exclude"` Exclude []string `json:"exclude"`
Include string `json:"include"` Include []string `json:"include"`
} }
// Create a fresh config with sensible defaults. // Create a fresh config with sensible defaults.
@@ -46,8 +49,13 @@ func NewConfig(privateKeyPaths []string) Config {
Keys: keys, Keys: keys,
ScanConfig: scanConfig{ ScanConfig: scanConfig{
Matcher: "\\.env", Matcher: "\\.env",
Exclude: "*.envrc", Exclude: []string{
Include: "~", "*\\.envrc",
"\\.local/",
"node_modules",
"vendor",
},
Include: []string{"~"},
}, },
} }
} }
@@ -107,19 +115,39 @@ func (c *Config) Save() error {
return os.WriteFile(configPath, data, 0644) return os.WriteFile(configPath, data, 0644)
} }
// buildFdArgs builds the fd command arguments with multiple exclude patterns
func (c Config) buildFdArgs(searchPath string, includeIgnored bool) []string {
args := []string{"-a", c.ScanConfig.Matcher}
// Add exclude patterns
for _, exclude := range c.ScanConfig.Exclude {
args = append(args, "-E", exclude)
}
if includeIgnored {
args = append(args, "-HI")
} else {
args = append(args, "-H")
}
args = append(args, searchPath)
return args
}
// Use fd to find all ignored .env files that match the config's parameters // Use fd to find all ignored .env files that match the config's parameters
func (c Config) scan() (paths []string, err error) { func (c Config) scan() (paths []string, err error) {
searchPath, err := c.searchPath() searchPaths, err := c.searchPaths()
if err != nil { if err != nil {
return []string{}, err return []string{}, err
} }
for _, searchPath := range searchPaths {
// Find all files (including ignored ones) // Find all files (including ignored ones)
fmt.Printf("Searching for all files in \"%s\"...\n", searchPath) fmt.Printf("Searching for all files in \"%s\"...\n", searchPath)
allCmd := exec.Command("fd", "-a", c.ScanConfig.Matcher, "-E", c.ScanConfig.Exclude, "-HI", searchPath) allCmd := exec.Command("fd", c.buildFdArgs(searchPath, true)...)
allOutput, err := allCmd.Output() allOutput, err := allCmd.Output()
if err != nil { if err != nil {
return []string{}, err return paths, err
} }
allFiles := strings.Split(strings.TrimSpace(string(allOutput)), "\n") allFiles := strings.Split(strings.TrimSpace(string(allOutput)), "\n")
@@ -129,7 +157,7 @@ func (c Config) scan() (paths []string, err error) {
// Find unignored files // Find unignored files
fmt.Printf("Search for unignored fies in \"%s\"...\n", searchPath) fmt.Printf("Search for unignored fies in \"%s\"...\n", searchPath)
unignoredCmd := exec.Command("fd", "-a", c.ScanConfig.Matcher, "-E", c.ScanConfig.Exclude, "-H", searchPath) unignoredCmd := exec.Command("fd", c.buildFdArgs(searchPath, false)...)
unignoredOutput, err := unignoredCmd.Output() unignoredOutput, err := unignoredCmd.Output()
if err != nil { if err != nil {
return []string{}, err return []string{}, err
@@ -154,30 +182,34 @@ func (c Config) scan() (paths []string, err error) {
} }
} }
return ignoredFiles, nil paths = append(paths, ignoredFiles...)
} }
func (c Config) searchPath() (path string, err error) { return paths, nil
include := c.ScanConfig.Include }
if include == "~" { func (c Config) searchPaths() (paths []string, err error) {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", err return paths, err
}
return homeDir, nil
} }
absPath, err := filepath.Abs(include) includes := c.ScanConfig.Include
for _, include := range includes {
path := strings.Replace(include, "~", homeDir, 1)
absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
return "", err return paths, err
} }
return absPath, nil paths = append(paths, absPath)
} }
// TODO: Should this be private? return paths, nil
func (s SshKeyPair) Identity() (age.Identity, error) { }
func (s SshKeyPair) identity() (age.Identity, error) {
sshKey, err := os.ReadFile(s.Private) sshKey, err := os.ReadFile(s.Private)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read SSH key: %w", err) return nil, fmt.Errorf("failed to read SSH key: %w", err)
@@ -191,8 +223,7 @@ func (s SshKeyPair) Identity() (age.Identity, error) {
return id, nil return id, nil
} }
// TODO: Should this be private? func (s SshKeyPair) recipient() (age.Recipient, error) {
func (s SshKeyPair) Recipient() (age.Recipient, error) {
sshKey, err := os.ReadFile(s.Public) sshKey, err := os.ReadFile(s.Public)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read SSH key: %w", err) return nil, fmt.Errorf("failed to read SSH key: %w", err)
@@ -205,3 +236,32 @@ func (s SshKeyPair) Recipient() (age.Recipient, error) {
return id, nil return id, nil
} }
// Use fd to find all git roots in the config's search paths
func (c Config) findGitRoots() (paths []string, err error) {
searchPaths, err := c.searchPaths()
if err != nil {
return []string{}, err
}
for _, searchPath := range searchPaths {
allCmd := exec.Command("fd", "-H", "-t", "d", "^\\.git$", searchPath)
allOutput, err := allCmd.Output()
if err != nil {
return paths, err
}
allFiles := strings.Split(strings.TrimSpace(string(allOutput)), "\n")
if len(allFiles) == 1 && allFiles[0] == "" {
allFiles = []string{}
}
for i, file := range allFiles {
allFiles[i] = path.Dir(path.Clean(file))
}
paths = append(paths, allFiles...)
}
return paths, nil
}

101
app/db.go
View File

@@ -1,5 +1,6 @@
package app package app
// TODO: app/db.go should be reviewed.
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
@@ -13,19 +14,12 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
// CloseMode determines whether or not the in-memory DB should be saved to disk
// before closing the connection.
type CloseMode int
const (
ReadOnly CloseMode = iota
Write
)
type Db struct { type Db struct {
db *sql.DB db *sql.DB
cfg Config cfg Config
features *AvailableFeatures features *AvailableFeatures
// If true, the database will be saved to disk before closing
changed bool
} }
func Open() (*Db, error) { func Open() (*Db, error) {
@@ -37,7 +31,7 @@ func Open() (*Db, error) {
if _, err := os.Stat("/home/spencer/.envr/data.age"); err != nil { if _, err := os.Stat("/home/spencer/.envr/data.age"); err != nil {
// Create a new DB // Create a new DB
db, err := newDb() db, err := newDb()
return &Db{db, *cfg, nil}, err return &Db{db, *cfg, nil, true}, err
} else { } else {
// Open the existing DB // Open the existing DB
tmpFile, err := os.CreateTemp("", "envr-*.db") tmpFile, err := os.CreateTemp("", "envr-*.db")
@@ -59,7 +53,7 @@ func Open() (*Db, error) {
restoreDB(tmpFile.Name(), memDb) restoreDB(tmpFile.Name(), memDb)
return &Db{memDb, *cfg, nil}, nil return &Db{memDb, *cfg, nil, false}, nil
} }
} }
@@ -72,7 +66,6 @@ func newDb() (*sql.DB, error) {
} else { } else {
_, err := db.Exec(`create table envr_env_files ( _, err := db.Exec(`create table envr_env_files (
path text primary key not null path text primary key not null
, dir text not null
, remotes text -- JSON , remotes text -- JSON
, sha256 text not null , sha256 text not null
, contents text not null , contents text not null
@@ -108,7 +101,7 @@ func decryptDb(tmpFilePath string, keys []SshKeyPair) error {
identities := make([]age.Identity, 0, len(keys)) identities := make([]age.Identity, 0, len(keys))
for _, key := range keys { for _, key := range keys {
id, err := key.Identity() id, err := key.identity()
if err != nil { if err != nil {
return err return err
@@ -150,7 +143,7 @@ func restoreDB(path string, destDB *sql.DB) error {
// Returns all the EnvFiles present in the database. // Returns all the EnvFiles present in the database.
func (db *Db) List() (results []EnvFile, err error) { func (db *Db) List() (results []EnvFile, err error) {
rows, err := db.db.Query("select * from envr_env_files") rows, err := db.db.Query("select path, remotes, sha256, contents from envr_env_files")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -159,14 +152,18 @@ func (db *Db) List() (results []EnvFile, err error) {
for rows.Next() { for rows.Next() {
var envFile EnvFile var envFile EnvFile
var remotesJSON string var remotesJson []byte
err := rows.Scan(&envFile.Path, &remotesJson, &envFile.Sha256, &envFile.contents)
err := rows.Scan(&envFile.Path, &envFile.Dir, &remotesJSON, &envFile.Sha256, &envFile.contents)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// TODO: unmarshal remotesJSON into envFile.remotes // Populate Dir from Path
envFile.Dir = filepath.Dir(envFile.Path)
if err := json.Unmarshal(remotesJson, &envFile.Remotes); err != nil {
return nil, err
}
results = append(results, envFile) results = append(results, envFile)
} }
@@ -178,10 +175,10 @@ func (db *Db) List() (results []EnvFile, err error) {
return results, nil return results, nil
} }
func (db *Db) Close(mode CloseMode) error { func (db *Db) Close() error {
defer db.db.Close() defer db.db.Close()
if mode == Write { if db.changed {
// Create tmp file // Create tmp file
tmpFile, err := os.CreateTemp("", "envr-*.db") tmpFile, err := os.CreateTemp("", "envr-*.db")
if err != nil { if err != nil {
@@ -197,6 +194,8 @@ func (db *Db) Close(mode CloseMode) error {
if err := encryptDb(tmpFile.Name(), db.cfg.Keys); err != nil { if err := encryptDb(tmpFile.Name(), db.cfg.Keys); err != nil {
return err return err
} }
db.changed = false
} }
return nil return nil
@@ -242,7 +241,7 @@ func encryptDb(tmpFilePath string, keys []SshKeyPair) error {
recipients := make([]age.Recipient, 0, len(keys)) recipients := make([]age.Recipient, 0, len(keys))
for _, key := range keys { for _, key := range keys {
recipient, err := key.Recipient() recipient, err := key.recipient()
if err != nil { if err != nil {
return err return err
@@ -278,14 +277,16 @@ func (db *Db) Insert(file EnvFile) error {
// Insert into database // Insert into database
_, err = db.db.Exec(` _, err = db.db.Exec(`
INSERT OR REPLACE INTO envr_env_files (path, dir, remotes, sha256, contents) INSERT OR REPLACE INTO envr_env_files (path, remotes, sha256, contents)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?)
`, file.Path, file.Dir, string(remotesJSON), file.Sha256, file.contents) `, file.Path, string(remotesJSON), file.Sha256, file.contents)
if err != nil { if err != nil {
return fmt.Errorf("failed to insert env file: %w", err) return fmt.Errorf("failed to insert env file: %w", err)
} }
db.changed = true
return nil return nil
} }
@@ -293,12 +294,15 @@ func (db *Db) Insert(file EnvFile) error {
func (db *Db) Fetch(path string) (envFile EnvFile, err error) { func (db *Db) Fetch(path string) (envFile EnvFile, err error) {
var remotesJSON string var remotesJSON string
row := db.db.QueryRow("SELECT path, dir, remotes, sha256, contents FROM envr_env_files WHERE path = ?", path) row := db.db.QueryRow("SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?", path)
err = row.Scan(&envFile.Path, &envFile.Dir, &remotesJSON, &envFile.Sha256, &envFile.contents) err = row.Scan(&envFile.Path, &remotesJSON, &envFile.Sha256, &envFile.contents)
if err != nil { if err != nil {
return EnvFile{}, fmt.Errorf("failed to fetch env file: %w", err) return EnvFile{}, fmt.Errorf("failed to fetch env file: %w", err)
} }
// Populate Dir from Path
envFile.Dir = filepath.Dir(envFile.Path)
if err = json.Unmarshal([]byte(remotesJSON), &envFile.Remotes); err != nil { if err = json.Unmarshal([]byte(remotesJSON), &envFile.Remotes); err != nil {
return EnvFile{}, fmt.Errorf("failed to unmarshal remotes: %w", err) return EnvFile{}, fmt.Errorf("failed to unmarshal remotes: %w", err)
} }
@@ -322,12 +326,21 @@ func (db *Db) Delete(path string) error {
return fmt.Errorf("no file found with path: %s", path) return fmt.Errorf("no file found with path: %s", path)
} }
db.changed = true
return nil return nil
} }
// Finds .env files in the filesystem that aren't present in the database. // Finds .env files in the filesystem that aren't present in the database.
func (db *Db) Scan() ([]string, error) { // path overrides the already configured
all_paths, err := db.cfg.scan() func (db *Db) Scan(paths []string) ([]string, error) {
cfg := db.cfg
if paths != nil {
cfg.ScanConfig.Include = paths
}
all_paths, err := cfg.scan()
if err != nil { if err != nil {
return []string{}, err return []string{}, err
} }
@@ -374,3 +387,35 @@ func (db *Db) CanScan() error {
return nil return nil
} }
} }
// If true, [Db.Insert] should be called on the [EnvFile] that generated
// the given result
func (db Db) UpdateRequired(status EnvFileSyncResult) bool {
return status&(BackedUp|DirUpdated) != 0
}
func (db *Db) Sync(file *EnvFile) (result EnvFileSyncResult, err error) {
// TODO: This results in findMovedDirs being called multiple times.
return file.sync(TrustFilesystem, db)
}
// Looks for git directories that share one or more git remotes with
// the given file.
func (db Db) findMovedDirs(file *EnvFile) (movedDirs []string, err error) {
if err = db.Features().validateFeatures(Fd, Git); err != nil {
return movedDirs, err
}
gitRoots, err := db.cfg.findGitRoots()
if err != nil {
return movedDirs, err
} else {
for _, dir := range gitRoots {
if file.sharesRemote(getGitRemotes(dir)) {
movedDirs = append(movedDirs, dir)
}
}
return movedDirs, nil
}
}

View File

@@ -2,15 +2,19 @@ package app
import ( import (
"crypto/sha256" "crypto/sha256"
"errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
) )
type EnvFile struct { type EnvFile struct {
// TODO: Should use FileName in the struct and derive from the path.
Path string Path string
// Dir is derived from Path, and is not stored in the database.
Dir string Dir string
Remotes []string // []string Remotes []string // []string
Sha256 string Sha256 string
@@ -21,16 +25,30 @@ type EnvFile struct {
type EnvFileSyncResult int type EnvFileSyncResult int
const ( const (
// The struct has been updated from the filesystem
// and should be updated in the database.
Updated EnvFileSyncResult = iota
// The filesystem has been restored to match the struct
// no further action is required.
Restored
Error
// The filesystem contents matches the struct // The filesystem contents matches the struct
// no further action is required. // no further action is required.
Noop Noop EnvFileSyncResult = 0
// The directory changed, but the file contents matched.
// The database must be updated.
DirUpdated EnvFileSyncResult = 1
// The filesystem has been restored to match the struct
// no further action is required.
Restored EnvFileSyncResult = 1 << 1
// The filesystem has been restored to match the struct.
// The directory changed, so the database must be updated
RestoredAndDirUpdated EnvFileSyncResult = Restored | DirUpdated
// The struct has been updated from the filesystem
// and should be updated in the database.
BackedUp EnvFileSyncResult = 1 << 2
Error EnvFileSyncResult = 1 << 3
)
// Determines the source of truth when calling [EnvFile.Sync] or [EnvFile.Restore]
type syncDirection int
const (
TrustDatabase syncDirection = iota
TrustFilesystem
) )
func NewEnvFile(path string) EnvFile { func NewEnvFile(path string) EnvFile {
@@ -95,62 +113,119 @@ func getGitRemotes(dir string) []string {
return remotes return remotes
} }
// Install the file into the file system // Reconcile the state of the database with the state of the filesystem, using
func (file EnvFile) Restore() error { // dir to determine which side to use a the source of truth.
// TODO: Handle restores more cleanly func (f *EnvFile) sync(dir syncDirection, db *Db) (result EnvFileSyncResult, err error) {
// Ensure the directory exists if result != Noop {
if err := os.MkdirAll(filepath.Dir(file.Path), 0755); err != nil { panic("Invalid state")
return fmt.Errorf("failed to create directory: %w", err)
} }
// Check if file already exists if _, err := os.Stat(f.Dir); err != nil {
if _, err := os.Stat(file.Path); err == nil { // Directory doesn't exist
return fmt.Errorf("file already exists: %s", file.Path)
var movedDirs []string
if db != nil {
movedDirs, err = db.findMovedDirs(f)
}
if err != nil {
return Error, err
} else {
switch len(movedDirs) {
case 0:
return Error, fmt.Errorf("directory missing")
case 1:
f.updateDir(movedDirs[0])
result |= DirUpdated
default:
return Error, fmt.Errorf("multiple directories found")
}
}
} }
// Write the contents to the file if _, err := os.Stat(f.Path); err != nil {
if err := os.WriteFile(file.Path, []byte(file.contents), 0644); err != nil { if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to write file: %w", err) if err := os.WriteFile(f.Path, []byte(f.contents), 0644); err != nil {
return Error, fmt.Errorf("failed to write file: %w", err)
} }
return nil return result | Restored, nil
} else {
return Error, err
}
} else {
// File exists, check its hash
contents, err := os.ReadFile(f.Path)
if err != nil {
return Error, fmt.Errorf("failed to read file for SHA comparison: %w", err)
}
hash := sha256.Sum256(contents)
currentSha := fmt.Sprintf("%x", hash)
// Compare the hashes
if currentSha == f.Sha256 {
// No op, or DirUpdated
return result, nil
} else {
switch dir {
case TrustDatabase:
if err := os.WriteFile(f.Path, []byte(f.contents), 0644); err != nil {
return Error, fmt.Errorf("failed to write file: %w", err)
}
return result | Restored, nil
case TrustFilesystem:
// Overwrite the database
if err = f.Backup(); err != nil {
return Error, err
} else {
return BackedUp, nil
}
default:
panic("unknown sync direction")
}
}
}
}
func (f *EnvFile) sharesRemote(remotes []string) bool {
rMap := make(map[string]bool)
for _, remote := range f.Remotes {
rMap[remote] = true
}
for _, remote := range remotes {
if rMap[remote] {
return true
}
}
return false
}
func (f *EnvFile) updateDir(newDir string) {
f.Dir = newDir
f.Path = path.Join(newDir, path.Base(f.Path))
f.Remotes = getGitRemotes(newDir)
} }
// Try to reconcile the EnvFile with the filesystem. // Try to reconcile the EnvFile with the filesystem.
// //
// If Updated is returned, [Db.Insert] should be called on file. // If Updated is returned, [Db.Insert] should be called on file.
func (file *EnvFile) Sync() (result EnvFileSyncResult, err error) { func (file *EnvFile) Sync() (result EnvFileSyncResult, err error) {
// Check if the path exists in the file system return file.sync(TrustFilesystem, nil)
_, err = os.Stat(file.Path)
if err == nil {
contents, err := os.ReadFile(file.Path)
if err != nil {
return Error, fmt.Errorf("failed to read file for SHA comparison: %w", err)
} }
// Check if sha matches by reading the current file and calculating its hash // Install the file into the file system. If the file already exists,
hash := sha256.Sum256(contents) // it will be overwritten.
currentSha := fmt.Sprintf("%x", hash) func (file EnvFile) Restore() error {
if file.Sha256 == currentSha { _, err := file.sync(TrustDatabase, nil)
// Nothing to do
return Noop, nil return err
} else {
if err = file.Backup(); err != nil {
return Error, err
} else {
return Updated, nil
}
}
} else {
if err = file.Restore(); err != nil {
return Error, err
} else {
return Restored, nil
}
}
} }
// Update the EnvFile using the file system // Update the EnvFile using the file system.
func (file *EnvFile) Backup() error { func (file *EnvFile) Backup() error {
// Read the contents of the file // Read the contents of the file
contents, err := os.ReadFile(file.Path) contents, err := os.ReadFile(file.Path)

View File

@@ -1,9 +1,20 @@
package app package app
import ( import (
"fmt"
"os/exec" "os/exec"
) )
type MissingFeatureError struct {
feature AvailableFeatures
}
func (m *MissingFeatureError) Error() string {
return fmt.Sprintf("Missing \"%s\" feature", m.feature)
}
// TODO: Features should really be renamed to Binaries
// Represents which binaries are present in $PATH. // Represents which binaries are present in $PATH.
// Used to fail safely when required features are unavailable // Used to fail safely when required features are unavailable
type AvailableFeatures int type AvailableFeatures int
@@ -13,7 +24,7 @@ const (
// fd // fd
Fd AvailableFeatures = 2 Fd AvailableFeatures = 2
// All features are present // All features are present
All AvailableFeatures = Git & Fd All AvailableFeatures = Git | Fd
) )
// Checks for available features. // Checks for available features.
@@ -30,3 +41,20 @@ func checkFeatures() (feats AvailableFeatures) {
return feats return feats
} }
// Returns a MissingFeature error if the given features aren't present.
func (a AvailableFeatures) validateFeatures(features ...AvailableFeatures) error {
var missing AvailableFeatures
for _, feat := range features {
if a&feat == 0 {
missing |= feat
}
}
if missing == 0 {
return nil
} else {
return &MissingFeatureError{missing}
}
}

181
cli.odin Normal file
View File

@@ -0,0 +1,181 @@
package main
import "core:fmt"
import "core:os"
import "core:strings"
Command :: struct {
name: string,
args: [dynamic]string,
flags: map[string]string,
bool_set: map[string]bool,
}
CommandInfo :: struct {
name: string,
usage: string,
short: string,
long: string,
}
COMMANDS := []CommandInfo{
{"init", "envr init", "Set up envr",
"The init command generates your initial config and saves it to\n~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to\nencrypt your databse. **Make 100% sure** that you have **a remote copy** of this\nkey somewhere, otherwise your data could be lost forever."},
{"scan", "envr scan", "Find and select .env files for backup", ""},
{"sync", "envr sync", "Update or restore your env backups", ""},
{"backup", "envr backup <path>", "Import a .env file into envr", ""},
{"add", "envr add <path>", "Import a .env file into envr", ""},
{"restore", "envr restore <path>", "Restore a .env file from the database", ""},
{"list", "envr list", "View your tracked files", ""},
{"remove", "envr remove <path>", "Remove a .env file from your database", ""},
{"check", "envr check [path]", "Check if files are backed up", ""},
{"deps", "envr deps", "Check for missing binaries",
"envr relies on external binaries for certain functionality.\n\nThe check command reports on which binaries are available and which are not."},
{"version", "envr version", "Show envr's version", ""},
{"edit-config", "envr edit-config", "Edit your config with your default editor", ""},
}
IMPLEMENTED_COMMANDS := []string{
"version",
"deps",
"list",
"backup",
"add",
"remove",
"restore",
"edit-config",
}
parse_args :: proc() -> (cmd: Command, ok: bool) {
args := os.args
if len(args) < 2 {
print_usage()
return Command{}, false
}
cmd.name = args[1]
if cmd.name == "--help" || cmd.name == "-h" {
print_usage()
return Command{}, false
}
cmd.args = make([dynamic]string)
cmd.flags = make(map[string]string)
cmd.bool_set = make(map[string]bool)
i := 2
for i < len(args) {
arg := args[i]
if strings.starts_with(arg, "--") {
key := arg[2:]
if i+1 < len(args) && !strings.starts_with(args[i+1], "-") {
cmd.flags[key] = args[i+1]
i += 2
} else {
cmd.bool_set[key] = true
i += 1
}
} else if strings.starts_with(arg, "-") && len(arg) == 2 {
key_slice := arg[1:2]
if i+1 < len(args) && !strings.starts_with(args[i+1], "-") {
cmd.flags[key_slice] = args[i+1]
i += 2
} else {
cmd.bool_set[key_slice] = true
i += 1
}
} else {
append(&cmd.args, arg)
i += 1
}
}
if has_flag(&cmd, "help") {
print_command_help(cmd.name)
return Command{}, false
}
return cmd, true
}
is_implemented :: proc(name: string) -> bool {
for c in IMPLEMENTED_COMMANDS {
if c == name {
return true
}
}
return false
}
has_flag :: proc(cmd: ^Command, name: string) -> bool {
_, ok := cmd.flags[name]
if ok {
return true
}
_, ok2 := cmd.bool_set[name]
return ok2
}
find_command :: proc(name: string) -> (CommandInfo, bool) {
for c in COMMANDS {
if c.name == name {
return c, true
}
}
return CommandInfo{}, false
}
print_command_help :: proc(name: string) {
info, found := find_command(name)
if !found {
fmt.printf("Unknown command: %s\n", name)
print_usage()
return
}
fmt.printf("Usage: %s\n\n%s\n", info.usage, info.short)
if len(info.long) > 0 {
fmt.printf("\n%s\n", info.long)
}
}
print_usage :: proc() {
fmt.println("envr - Manage your .env files.")
fmt.println("")
fmt.println("envr keeps your .env synced to a local, age encrypted database.")
fmt.println("Is a safe and easy way to gather all your .env files in one place where they can")
fmt.println("easily be backed by another tool such as restic or git.")
fmt.println("")
fmt.println("All your data is stored in ~/data.age")
fmt.println("")
fmt.println("Getting started is easy:")
fmt.println("")
fmt.println("1. Create your configuration file and set up encrypted storage:")
fmt.println("")
fmt.println("> envr init")
fmt.println("")
fmt.println("2. Scan for existing .env files:")
fmt.println("")
fmt.println("> envr scan")
fmt.println("")
fmt.println("Select the files you want to back up from the interactive list.")
fmt.println("")
fmt.println("3. Verify that it worked:")
fmt.println("")
fmt.println("> envr list")
fmt.println("")
fmt.println("Usage: envr <command> [args]")
fmt.println("")
fmt.println("Commands:")
fmt.println(" init Set up envr")
fmt.println(" scan Find and select .env files for backup")
fmt.println(" sync Update or restore your env backups")
fmt.println(" backup <path> Import a .env file into envr")
fmt.println(" restore <path> Restore a .env file from the database")
fmt.println(" list View your tracked files")
fmt.println(" remove <path> Remove a .env file from your database")
fmt.println(" check [path] Check if files are backed up")
fmt.println(" deps Check for missing binaries")
fmt.println(" version Show envr's version")
fmt.println(" edit-config Edit your config with your default editor")
}

View File

@@ -15,6 +15,7 @@ import (
var backupCmd = &cobra.Command{ var backupCmd = &cobra.Command{
Use: "backup <path>", Use: "backup <path>",
Short: "Import a .env file into envr", Short: "Import a .env file into envr",
Aliases: []string{"add"},
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
// Long: `Long desc` // Long: `Long desc`
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -27,11 +28,11 @@ var backupCmd = &cobra.Command{
if err != nil { if err != nil {
return err return err
} else { } else {
defer db.Close(app.Write) defer db.Close()
record := app.NewEnvFile(path) record := app.NewEnvFile(path)
if err := db.Insert(record); err != nil { if err := db.Insert(record); err != nil {
panic(err) return err
} else { } else {
fmt.Printf("Saved %s into the database", path) fmt.Printf("Saved %s into the database", path)
return nil return nil

View File

@@ -1,48 +1,106 @@
package cmd package cmd
import ( import (
"fmt"
"os" "os"
"path/filepath"
"github.com/olekukonko/tablewriter"
"github.com/sbrow/envr/app" "github.com/sbrow/envr/app"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var checkCmd = &cobra.Command{ var checkCmd = &cobra.Command{
Use: "check", Use: "check [path]",
Short: "Check for missing binaries", Short: "check if files in the current directory are backed up",
Long: `envr relies on external binaries for certain functionality. // TODO: Long description for new check command
Args: cobra.MaximumNArgs(1),
The check command reports on which binaries are available and which are not.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
// Accept an optional path arg, default to current working directory
var checkPath string
if len(args) > 0 {
checkPath = args[0]
} else {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current working directory: %w", err)
}
checkPath = cwd
}
// Get absolute path
absPath, err := filepath.Abs(checkPath)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
// Open database
db, err := app.Open() db, err := app.Open()
if err != nil { if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer db.Close()
// Check if the path is a file or directory
info, err := os.Stat(absPath)
if err != nil {
return fmt.Errorf("failed to stat path: %w", err)
}
var filesInPath []string
if info.IsDir() {
// Find .env files in the specified directory
if err := db.CanScan(); err != nil {
return err return err
} else {
defer db.Close(app.ReadOnly)
features := db.Features()
table := tablewriter.NewWriter(os.Stdout)
table.Header([]string{"Feature", "Status"})
// Check Git
if features&app.Git == 1 {
table.Append([]string{"Git", "✓ Available"})
} else {
table.Append([]string{"Git", "✗ Missing"})
} }
// Check fd // Scan only the specified path for .env files
if features&app.Fd == 1 { filesInPath, err = db.Scan([]string{absPath})
table.Append([]string{"fd", "✓ Available"}) if err != nil {
return fmt.Errorf("failed to scan path for env files: %w", err)
}
} else { } else {
table.Append([]string{"fd", "✗ Missing"}) // Path is a file, just check this specific file
filesInPath = []string{absPath}
} }
table.Render() // Get all backed up files from the database
envFiles, err := db.List()
if err != nil {
return fmt.Errorf("failed to list files from database: %w", err)
}
// Check which files are not backed up
var notBackedUp []string
for _, file := range filesInPath {
isBackedUp := false
for _, envFile := range envFiles {
if envFile.Path == file {
isBackedUp = true
break
}
}
if !isBackedUp {
notBackedUp = append(notBackedUp, file)
}
}
// Display results
if len(notBackedUp) == 0 {
if len(filesInPath) == 0 {
fmt.Println("No .env files found in the specified directory.")
} else {
fmt.Println("✓ All .env files in the directory are backed up.")
}
} else {
fmt.Printf("Found %d .env file(s) that are not backed up:\n", len(notBackedUp))
for _, file := range notBackedUp {
fmt.Printf(" %s\n", file)
}
fmt.Println("\nRun 'envr sync' to back up these files.")
}
return nil return nil
}
}, },
} }

51
cmd/deps.go Normal file
View File

@@ -0,0 +1,51 @@
package cmd
import (
"os"
"github.com/olekukonko/tablewriter"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var depsCmd = &cobra.Command{
Use: "deps",
Short: "Check for missing binaries",
Long: `envr relies on external binaries for certain functionality.
The check command reports on which binaries are available and which are not.`,
RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close()
features := db.Features()
table := tablewriter.NewWriter(os.Stdout)
table.Header([]string{"Feature", "Status"})
// Check Git
if features&app.Git == 1 {
table.Append([]string{"Git", "✓ Available"})
} else {
table.Append([]string{"Git", "✗ Missing"})
}
// Check fd
if features&app.Fd == app.Fd {
table.Append([]string{"fd", "✓ Available"})
} else {
table.Append([]string{"fd", "✗ Missing"})
}
table.Render()
return nil
}
},
}
func init() {
rootCmd.AddCommand(depsCmd)
}

View File

@@ -11,10 +11,8 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// TODO: Add --force (-f) flag.
var initCmd = &cobra.Command{ var initCmd = &cobra.Command{
Use: "init", Use: "init",
DisableFlagsInUseLine: true,
Short: "Set up envr", Short: "Set up envr",
Long: `The init command generates your initial config and saves it to Long: `The init command generates your initial config and saves it to
~/.envr/config in JSON format. ~/.envr/config in JSON format.
@@ -23,11 +21,10 @@ During setup, you will be prompted to select one or more ssh keys with which to
encrypt your databse. **Make 100% sure** that you have **a remote copy** of this encrypt your databse. **Make 100% sure** that you have **a remote copy** of this
key somewhere, otherwise your data could be lost forever.`, key somewhere, otherwise your data could be lost forever.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
config, _ := app.LoadConfig() config, _ := app.LoadConfig()
if config != nil { if config == nil || force {
return fmt.Errorf("You have already initialized envr")
} else {
keys, err := selectSSHKeys() keys, err := selectSSHKeys()
if err != nil { if err != nil {
return fmt.Errorf("Error selecting SSH keys: %v", err) return fmt.Errorf("Error selecting SSH keys: %v", err)
@@ -43,13 +40,17 @@ key somewhere, otherwise your data could be lost forever.`,
} }
fmt.Printf("Config initialized with %d SSH key(s). You are ready to use envr.\n", len(keys)) fmt.Printf("Config initialized with %d SSH key(s). You are ready to use envr.\n", len(keys))
}
return nil return nil
} else {
return fmt.Errorf(`You have already initialized envr.
Run again with the --force flag if you want to reinitialize.
`)
}
}, },
} }
func init() { func init() {
initCmd.Flags().BoolP("force", "f", false, "Overwrite an existing config")
rootCmd.AddCommand(initCmd) rootCmd.AddCommand(initCmd)
} }

View File

@@ -24,7 +24,7 @@ var listCmd = &cobra.Command{
if err != nil { if err != nil {
return err return err
} }
defer db.Close(app.ReadOnly) defer db.Close()
rows, err := db.List() rows, err := db.List()
if err != nil { if err != nil {

View File

@@ -25,7 +25,7 @@ var removeCmd = &cobra.Command{
if err != nil { if err != nil {
return err return err
} else { } else {
defer db.Close(app.Write) defer db.Close()
if err := db.Delete(path); err != nil { if err := db.Delete(path); err != nil {
return err return err
} else { } else {

View File

@@ -27,7 +27,7 @@ var restoreCmd = &cobra.Command{
if err != nil { if err != nil {
return err return err
} else { } else {
defer db.Close(app.ReadOnly) defer db.Close()
record, err := db.Fetch(path) record, err := db.Fetch(path)
if err != nil { if err != nil {

View File

@@ -28,7 +28,7 @@ var scanCmd = &cobra.Command{
return err return err
} }
files, err := db.Scan() files, err := db.Scan(nil)
if err != nil { if err != nil {
return err return err
} }
@@ -57,7 +57,7 @@ var scanCmd = &cobra.Command{
// Close database with write mode to persist changes // Close database with write mode to persist changes
if addedCount > 0 { if addedCount > 0 {
err = db.Close(app.Write) err = db.Close()
if err != nil { if err != nil {
return fmt.Errorf("Error saving changes: %v\n", err) return fmt.Errorf("Error saving changes: %v\n", err)
} else { } else {
@@ -65,7 +65,7 @@ var scanCmd = &cobra.Command{
return nil return nil
} }
} else { } else {
err = db.Close(app.ReadOnly) err = db.Close()
if err != nil { if err != nil {
return fmt.Errorf("Error closing database: %v\n", err) return fmt.Errorf("Error closing database: %v\n", err)
} }

View File

@@ -1,54 +1,93 @@
package cmd package cmd
import ( import (
"fmt" "encoding/json"
"os"
"github.com/mattn/go-isatty"
"github.com/olekukonko/tablewriter"
"github.com/sbrow/envr/app" "github.com/sbrow/envr/app"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// TODO: Detect when file paths have moved and update accordingly.
var syncCmd = &cobra.Command{ var syncCmd = &cobra.Command{
Use: "sync", Use: "sync",
Short: "Update or restore your env backups", Short: "Update or restore your env backups",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open() db, err := app.Open()
if err != nil { if err != nil {
return err return err
} else { } else {
defer db.Close(app.Write) defer db.Close()
files, err := db.List() files, err := db.List()
if err != nil { if err != nil {
return err return err
} else { } else {
type syncResult struct {
Path string `json:"path"`
Status string `json:"status"`
}
var results []syncResult
for _, file := range files { for _, file := range files {
fmt.Printf("%s\n", file.Path)
// Syncronize the filesystem with the database. // Syncronize the filesystem with the database.
changed, err := file.Sync() oldPath := file.Path
changed, err := db.Sync(&file)
var status string
switch changed { switch changed {
case app.Updated: case app.BackedUp:
fmt.Printf("File updated - changes saved\n") status = "Backed Up"
if err := db.Insert(file); err != nil { if err := db.Insert(file); err != nil {
return err return err
} }
case app.Restored: case app.Restored:
fmt.Printf("File missing - restored backup\n") fallthrough
case app.RestoredAndDirUpdated:
status = "Restored"
case app.Error: case app.Error:
if err == nil { if err == nil {
panic("err cannot be nil when Sync returns Error") panic("err cannot be nil when Sync returns Error")
} else {
fmt.Printf("%s\n", err)
} }
status = err.Error()
case app.Noop: case app.Noop:
fmt.Println("Nothing to do") status = "OK"
case app.DirUpdated:
status = "Moved"
default: default:
panic("Unknown result") panic("Unknown result")
} }
fmt.Println("") if changed&app.DirUpdated == app.DirUpdated {
if err := db.Delete(oldPath); err != nil {
return err
}
}
if db.UpdateRequired(changed) {
if err := db.Insert(file); err != nil {
return err
}
}
results = append(results, syncResult{
Path: file.Path,
Status: status,
})
}
if isatty.IsTerminal(os.Stdout.Fd()) {
table := tablewriter.NewWriter(os.Stdout)
table.Header([]string{"File", "Status"})
for _, result := range results {
table.Append([]string{result.Path, result.Status})
}
table.Render()
} else {
encoder := json.NewEncoder(os.Stdout)
return encoder.Encode(results)
} }
return nil return nil

34
cmd_backup.odin Normal file
View File

@@ -0,0 +1,34 @@
package main
import "core:fmt"
import "core:strings"
cmd_backup :: proc(cmd: ^Command) {
if len(cmd.args) != 1 {
fmt.println("Usage: envr backup <path>")
return
}
path := cmd.args[0]
if len(strings.trim_space(path)) == 0 {
fmt.println("Error: No path provided")
return
}
file, ok := new_env_file(path)
if !ok {
return
}
db, db_ok := db_open()
if !db_ok {
return
}
defer db_close(&db)
if !db_insert(&db, file) {
return
}
fmt.printf("Saved %s into the database\n", path)
}

30
cmd_deps.odin Normal file
View File

@@ -0,0 +1,30 @@
package main
import "core:fmt"
cmd_deps :: proc(cmd: ^Command) {
feats := check_features()
headers := []string{"Feature", "Status"}
rows: [dynamic][]string
if .Git in feats {
append(&rows, []string{"Git", "\u2713 Available"})
} else {
append(&rows, []string{"Git", "\u2717 Missing"})
}
if .Fd in feats {
append(&rows, []string{"fd", "\u2713 Available"})
} else {
append(&rows, []string{"fd", "\u2717 Missing"})
}
if .Age in feats {
append(&rows, []string{"age", "\u2713 Available"})
} else {
append(&rows, []string{"age", "\u2717 Missing"})
}
render_table(headers, rows[:])
}

49
cmd_edit_config.odin Normal file
View File

@@ -0,0 +1,49 @@
package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
cmd_edit_config :: proc(cmd: ^Command) {
editor := os.get_env("EDITOR", context.allocator)
if len(editor) == 0 {
fmt.println("Error: $EDITOR environment variable is not set")
return
}
config_path, join_err := filepath.join([]string{envr_dir(), "config.json"})
if join_err != nil {
fmt.printf("Error building config path: %v\n", join_err)
return
}
_, stat_err := os.stat(config_path, context.allocator)
if stat_err != nil {
fmt.printf("Config file does not exist at %s. Run 'envr init' first.\n", config_path)
return
}
args := []string{editor, config_path}
desc := os.Process_Desc{
command = args,
stdin = os.stdin,
stdout = os.stdout,
stderr = os.stderr,
}
p, start_err := os.process_start(desc)
if start_err != nil {
fmt.printf("Error running editor: %v\n", start_err)
return
}
state, wait_err := os.process_wait(p)
if wait_err != nil {
fmt.printf("Error waiting for editor: %v\n", wait_err)
return
}
if state.exit_code != 0 {
os.exit(int(state.exit_code))
}
}

75
cmd_list.odin Normal file
View File

@@ -0,0 +1,75 @@
package main
import "core:encoding/json"
import "core:fmt"
import "core:path/filepath"
import "core:strings"
ListEntry :: struct {
Directory: string `json:"directory"`,
Path: string `json:"path"`,
}
cmd_list :: proc(cmd: ^Command) {
db, db_ok := db_open()
if !db_ok {
return
}
defer db_close(&db)
rows, list_ok := db_list(&db)
if !list_ok {
return
}
defer delete(rows)
if is_tty() {
headers := []string{"Directory", "Path"}
table_rows := make([dynamic][]string, 0, len(rows))
for row in rows {
b: strings.Builder
strings.builder_init(&b)
strings.write_string(&b, row.Dir)
strings.write_string(&b, "/")
dir_str, _ := strings.clone(strings.to_string(b))
rel, rel_err := filepath.rel(row.Dir, row.Path)
if rel_err != nil {
fmt.printf("Error getting relative path: %v\n", rel_err)
return
}
cloned_rel, _ := strings.clone(rel)
row_slice := make([]string, 2)
row_slice[0] = dir_str
row_slice[1] = cloned_rel
append(&table_rows, row_slice)
}
render_table(headers, table_rows[:])
} else {
entries: [dynamic]ListEntry
for row in rows {
rel, rel_err := filepath.rel(row.Dir, row.Path)
if rel_err != nil {
fmt.printf("Error getting relative path: %v\n", rel_err)
return
}
b: strings.Builder
strings.builder_init(&b)
strings.write_string(&b, row.Dir)
strings.write_string(&b, "/")
append(&entries, ListEntry{
Directory = strings.to_string(b),
Path = rel,
})
}
data, marshal_err := json.marshal(entries[:])
if marshal_err != nil {
fmt.printf("Error marshaling JSON: %v\n", marshal_err)
return
}
fmt.println(string(data))
}
}

42
cmd_remove.odin Normal file
View File

@@ -0,0 +1,42 @@
package main
import "core:fmt"
import "core:path/filepath"
import "core:strings"
cmd_remove :: proc(cmd: ^Command) {
if len(cmd.args) != 1 {
fmt.println("Usage: envr remove <path>")
return
}
path := cmd.args[0]
if len(strings.trim_space(path)) == 0 {
fmt.println("Error: No path provided")
return
}
abs_path: string
if filepath.is_abs(path) {
abs_path = path
} else {
resolved, abs_err := filepath.abs(path)
if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err)
return
}
abs_path = resolved
}
db, db_ok := db_open()
if !db_ok {
return
}
defer db_close(&db)
if !db_delete(&db, abs_path) {
return
}
fmt.printf("Removed %s from the database\n", abs_path)
}

53
cmd_restore.odin Normal file
View File

@@ -0,0 +1,53 @@
package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
cmd_restore :: proc(cmd: ^Command) {
if len(cmd.args) != 1 {
fmt.println("Usage: envr restore <path>")
return
}
path := cmd.args[0]
if len(strings.trim_space(path)) == 0 {
fmt.println("Error: No path provided")
return
}
abs_path: string
if filepath.is_abs(path) {
abs_path = path
} else {
resolved, abs_err := filepath.abs(path)
if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err)
return
}
abs_path = resolved
}
db, db_ok := db_open()
if !db_ok {
return
}
defer db_close(&db)
file, fetch_ok := db_fetch(&db, abs_path)
if !fetch_ok {
return
}
dir := filepath.dir(file.Path)
os.mkdir_all(dir)
write_err := os.write_entire_file(file.Path, file.contents)
if write_err != nil {
fmt.printf("Error writing file: %v\n", write_err)
return
}
fmt.printf("Restored %s\n", file.Path)
}

61
config.odin Normal file
View File

@@ -0,0 +1,61 @@
package main
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:path/filepath"
SshKeyPair :: struct {
Private: string `json:"private"`,
Public: string `json:"public"`,
}
ScanConfig :: struct {
Matcher: string `json:"matcher"`,
Exclude: []string `json:"exclude"`,
Include: []string `json:"include"`,
}
Config :: struct {
Keys: []SshKeyPair `json:"keys"`,
ScanConfig: ScanConfig `json:"scan"`,
}
load_config :: proc() -> (Config, bool) {
home, home_err := os.user_home_dir(context.allocator)
if home_err != nil {
fmt.printf("Error getting home dir: %v\n", home_err)
return Config{}, false
}
config_path, join_err := filepath.join([]string{home, ".envr", "config.json"})
if join_err != nil {
return Config{}, false
}
data, read_err := os.read_entire_file_from_path(config_path, context.allocator)
if read_err != nil {
fmt.println("No config file found. Please run `envr init` to generate one.")
return Config{}, false
}
cfg: Config
err := json.unmarshal(data, &cfg)
if err != nil {
fmt.printf("Error parsing config: %v\n", err)
return Config{}, false
}
return cfg, true
}
envr_dir :: proc() -> string {
home, _ := os.user_home_dir(context.allocator)
dir, _ := filepath.join([]string{home, ".envr"})
return dir
}
data_age_path :: proc() -> string {
dir := envr_dir()
path, _ := filepath.join([]string{dir, "data.age"})
return path
}

473
db.odin Normal file
View File

@@ -0,0 +1,473 @@
package main
import "core:c"
import "core:crypto/hash"
import "core:encoding/hex"
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:time"
import "sqlite"
Db :: struct {
db: ^rawptr,
cfg: Config,
changed: bool,
}
EnvFile :: struct {
Path: string,
Dir: string,
Remotes: [dynamic]string,
Sha256: string,
contents: string,
}
make_temp_path :: proc() -> string {
ts := time.time_to_unix(time.now())
b: strings.Builder
strings.builder_init(&b)
fmt.sbprintf(&b, "/tmp/envr-%d-%d.db", os.get_pid(), ts)
return strings.to_string(b)
}
db_open :: proc() -> (Db, bool) {
cfg, ok := load_config()
if !ok {
return Db{}, false
}
age_path := data_age_path()
_, stat_err := os.stat(age_path, context.allocator)
db: ^rawptr
rc := sqlite.db_open(":memory:", &db)
if rc != sqlite.OK {
fmt.printf("Error opening in-memory database: %s\n", sqlite.db_errmsg(db))
return Db{}, false
}
create_sql := "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)"
rc = sqlite.db_exec(db, string_to_cstring(create_sql), nil, nil, nil)
if rc != sqlite.OK {
fmt.printf("Error creating table: %s\n", sqlite.db_errmsg(db))
sqlite.db_close(db)
return Db{}, false
}
if stat_err == nil {
if !db_restore_from_age(db, cfg) {
sqlite.db_close(db)
return Db{}, false
}
}
return Db{db = db, cfg = cfg, changed = stat_err != nil}, true
}
db_close :: proc(d: ^Db) {
if d.changed {
tmp_path := make_temp_path()
if !db_vacuum_to_file(d.db, tmp_path) {
os.remove(tmp_path)
sqlite.db_close(d.db)
return
}
db_encrypt_file(tmp_path, d.cfg.Keys)
os.remove(tmp_path)
d.changed = false
}
sqlite.db_close(d.db)
}
db_list :: proc(d: ^Db) -> (results: [dynamic]EnvFile, ok: bool) {
sql := "SELECT path, remotes, sha256, contents FROM envr_env_files"
stmt: ^rawptr
rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil)
if rc != sqlite.OK {
fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db))
return
}
for {
rc = sqlite.step(stmt)
if rc == sqlite.DONE {
break
}
if rc != sqlite.ROW {
fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db))
sqlite.finalize(stmt)
return
}
path := cstring_to_string(sqlite.column_text(stmt, 0))
remotes_json := cstring_to_string(sqlite.column_text(stmt, 1))
sha := cstring_to_string(sqlite.column_text(stmt, 2))
contents := cstring_to_string(sqlite.column_text(stmt, 3))
remotes: [dynamic]string
if len(remotes_json) > 0 {
json.unmarshal_string(remotes_json, &remotes)
}
append(&results, EnvFile{
Path = path,
Dir = filepath.dir(path),
Remotes = remotes,
Sha256 = sha,
contents = contents,
})
}
sqlite.finalize(stmt)
ok = true
return
}
db_vacuum_to_file :: proc(db: ^rawptr, path: string) -> bool {
b: strings.Builder
strings.builder_init(&b)
fmt.sbprintf(&b, "VACUUM INTO '%s'", path)
sql := strings.to_string(b)
rc := sqlite.db_exec(db, string_to_cstring(sql), nil, nil, nil)
if rc != sqlite.OK {
fmt.printf("Error vacuuming database: %s\n", sqlite.db_errmsg(db))
return false
}
return true
}
db_restore_from_age :: proc(db: ^rawptr, cfg: Config) -> bool {
tmp_path := make_temp_path()
defer os.remove(tmp_path)
if !db_decrypt_to_file(tmp_path, cfg.Keys) {
return false
}
if !db_attach_and_copy(db, tmp_path) {
return false
}
return true
}
db_decrypt_to_file :: proc(tmp_path: string, keys: []SshKeyPair) -> bool {
age_path := data_age_path()
args := make([dynamic]string)
append(&args, "age")
append(&args, "--decrypt")
append(&args, "-o")
append(&args, tmp_path)
for key in keys {
append(&args, "-i")
append(&args, key.Private)
}
append(&args, age_path)
desc := os.Process_Desc{
command = args[:],
stdout = os.stderr,
stderr = os.stderr,
}
p, err := os.process_start(desc)
if err != nil {
fmt.printf("Error running age decrypt: %v\n", err)
return false
}
state, wait_err := os.process_wait(p)
if wait_err != nil {
fmt.printf("Error waiting for age: %v\n", wait_err)
return false
}
if state.exit_code != 0 {
fmt.println("Error: age decryption failed")
return false
}
return true
}
db_encrypt_file :: proc(tmp_path: string, keys: []SshKeyPair) -> bool {
age_path := data_age_path()
envr_d := envr_dir()
os.mkdir_all(envr_d)
args := make([dynamic]string)
append(&args, "age")
append(&args, "--encrypt")
for key in keys {
append(&args, "-r")
pub_data, pub_err := os.read_entire_file_from_path(key.Public, context.allocator)
if pub_err != nil {
fmt.printf("Error reading public key: %s\n", key.Public)
return false
}
pub_str := string(pub_data)
if strings.has_suffix(pub_str, "\n") {
pub_str = pub_str[:len(pub_str)-1]
}
append(&args, pub_str)
}
append(&args, "-o")
append(&args, age_path)
append(&args, tmp_path)
desc := os.Process_Desc{
command = args[:],
stdout = os.stderr,
stderr = os.stderr,
}
p, err := os.process_start(desc)
if err != nil {
fmt.printf("Error running age encrypt: %v\n", err)
return false
}
state, wait_err := os.process_wait(p)
if wait_err != nil {
fmt.printf("Error waiting for age: %v\n", wait_err)
return false
}
if state.exit_code != 0 {
fmt.println("Error: age encryption failed")
return false
}
return true
}
db_attach_and_copy :: proc(mem_db: ^rawptr, src_path: string) -> bool {
b: strings.Builder
strings.builder_init(&b)
fmt.sbprintf(&b, "ATTACH DATABASE '%s' AS source", src_path)
attach_sql := strings.to_string(b)
rc := sqlite.db_exec(mem_db, string_to_cstring(attach_sql), nil, nil, nil)
if rc != sqlite.OK {
fmt.printf("Error attaching database: %s\n", sqlite.db_errmsg(mem_db))
return false
}
rc = sqlite.db_exec(mem_db, "INSERT INTO main.envr_env_files SELECT * FROM source.envr_env_files", nil, nil, nil)
if rc != sqlite.OK {
fmt.printf("Error copying data: %s\n", sqlite.db_errmsg(mem_db))
sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil)
return false
}
sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil)
return true
}
get_git_remotes :: proc(dir: string) -> [dynamic]string {
remotes: [dynamic]string
remote_set: map[string]bool
b: strings.Builder
strings.builder_init(&b)
fmt.sbprintf(&b, "%s-git-remotes", make_temp_path())
tmp_path := strings.to_string(b)
tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC)
if tmp_err != nil {
return remotes
}
args := []string{"git", "remote", "-v"}
desc := os.Process_Desc{
command = args,
stdout = tmp_file,
stderr = nil,
working_dir = dir,
}
p, start_err := os.process_start(desc)
os.close(tmp_file)
if start_err != nil {
os.remove(tmp_path)
return remotes
}
state, wait_err := os.process_wait(p)
if wait_err != nil || state.exit_code != 0 {
os.remove(tmp_path)
return remotes
}
data, read_err := os.read_entire_file_from_path(tmp_path, context.allocator)
os.remove(tmp_path)
if read_err != nil {
return remotes
}
output_str := string(data)
lines := strings.split(output_str, "\n")
for &line in lines {
line = strings.trim_space(line)
if len(line) == 0 {
continue
}
parts := strings.fields(line)
if len(parts) >= 2 {
remote_set[parts[1]] = true
}
}
for remote, _ in remote_set {
cloned, _ := strings.clone(remote)
append(&remotes, cloned)
}
return remotes
}
new_env_file :: proc(path: string) -> (EnvFile, bool) {
abs_path, abs_err := filepath.abs(path)
if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err)
return EnvFile{}, false
}
cloned_path, _ := strings.clone(abs_path)
dir := filepath.dir(cloned_path)
cloned_dir, _ := strings.clone(dir)
remotes := get_git_remotes(cloned_dir)
data, read_err := os.read_entire_file_from_path(cloned_path, context.allocator)
if read_err != nil {
fmt.printf("Error reading file %s: %v\n", cloned_path, read_err)
return EnvFile{}, false
}
digest := hash.hash_bytes(hash.Algorithm.SHA256, data)
hex_bytes, _ := hex.encode(digest)
sha_str := string(hex_bytes)
return EnvFile{
Path = cloned_path,
Dir = cloned_dir,
Remotes = remotes,
Sha256 = sha_str,
contents = string(data),
}, true
}
db_insert :: proc(d: ^Db, file: EnvFile) -> bool {
remotes_json, marshal_err := json.marshal(file.Remotes)
if marshal_err != nil {
fmt.printf("Error marshaling remotes: %v\n", marshal_err)
return false
}
sql := "INSERT OR REPLACE INTO envr_env_files (path, remotes, sha256, contents) VALUES (?, ?, ?, ?)"
stmt: ^rawptr
rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil)
if rc != sqlite.OK {
fmt.printf("Error preparing insert: %s\n", sqlite.db_errmsg(d.db))
return false
}
defer sqlite.finalize(stmt)
rc = sqlite.bind_text(stmt, 1, string_to_cstring(file.Path), -1, nil)
rc = sqlite.bind_text(stmt, 2, string_to_cstring(string(remotes_json)), -1, nil)
rc = sqlite.bind_text(stmt, 3, string_to_cstring(file.Sha256), -1, nil)
rc = sqlite.bind_text(stmt, 4, string_to_cstring(file.contents), -1, nil)
rc = sqlite.step(stmt)
if rc != sqlite.DONE {
fmt.printf("Error inserting: %s\n", sqlite.db_errmsg(d.db))
return false
}
d.changed = true
return true
}
db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) {
sql := "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?"
stmt: ^rawptr
rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil)
if rc != sqlite.OK {
fmt.printf("Error preparing fetch: %s\n", sqlite.db_errmsg(d.db))
return EnvFile{}, false
}
defer sqlite.finalize(stmt)
rc = sqlite.bind_text(stmt, 1, string_to_cstring(path), -1, nil)
rc = sqlite.step(stmt)
if rc == sqlite.DONE {
fmt.printf("No file found with path: %s\n", path)
return EnvFile{}, false
}
if rc != sqlite.ROW {
fmt.printf("Error fetching: %s\n", sqlite.db_errmsg(d.db))
return EnvFile{}, false
}
file_path := cstring_to_string(sqlite.column_text(stmt, 0))
remotes_json := cstring_to_string(sqlite.column_text(stmt, 1))
sha := cstring_to_string(sqlite.column_text(stmt, 2))
contents := cstring_to_string(sqlite.column_text(stmt, 3))
remotes: [dynamic]string
if len(remotes_json) > 0 {
json.unmarshal_string(remotes_json, &remotes)
}
cloned_path, _ := strings.clone(file_path)
return EnvFile{
Path = cloned_path,
Dir = filepath.dir(cloned_path),
Remotes = remotes,
Sha256 = sha,
contents = contents,
}, true
}
db_delete :: proc(d: ^Db, path: string) -> bool {
sql := "DELETE FROM envr_env_files WHERE path = ?"
stmt: ^rawptr
rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil)
if rc != sqlite.OK {
fmt.printf("Error preparing delete: %s\n", sqlite.db_errmsg(d.db))
return false
}
defer sqlite.finalize(stmt)
rc = sqlite.bind_text(stmt, 1, string_to_cstring(path), -1, nil)
rc = sqlite.step(stmt)
if rc != sqlite.DONE {
fmt.printf("Error deleting: %s\n", sqlite.db_errmsg(d.db))
return false
}
if sqlite.changes(d.db) == 0 {
fmt.printf("No file found with path: %s\n", path)
return false
}
d.changed = true
return true
}
cstring_to_string :: proc(cs: cstring) -> string {
if cs == nil {
return ""
}
s, _ := strings.clone_from_cstring(cs)
return s
}
string_to_cstring :: proc(s: string) -> cstring {
cs, _ := strings.clone_to_cstring(s)
return cs
}

View File

@@ -44,7 +44,8 @@ at before, restore your backup with:
### SEE ALSO ### SEE ALSO
* [envr backup](envr_backup.md) - Import a .env file into envr * [envr backup](envr_backup.md) - Import a .env file into envr
* [envr check](envr_check.md) - Check for missing binaries * [envr check](envr_check.md) - check if files in the current directory are backed up
* [envr deps](envr_deps.md) - Check for missing binaries
* [envr edit-config](envr_edit-config.md) - Edit your config with your default editor * [envr edit-config](envr_edit-config.md) - Edit your config with your default editor
* [envr init](envr_init.md) - Set up envr * [envr init](envr_init.md) - Set up envr
* [envr list](envr_list.md) - View your tracked files * [envr list](envr_list.md) - View your tracked files

View File

@@ -1,15 +1,9 @@
## envr check ## envr check
Check for missing binaries check if files in the current directory are backed up
### Synopsis
envr relies on external binaries for certain functionality.
The check command reports on which binaries are available and which are not.
``` ```
envr check [flags] envr check [path] [flags]
``` ```
### Options ### Options

24
docs/cli/envr_deps.md Normal file
View File

@@ -0,0 +1,24 @@
## envr deps
Check for missing binaries
### Synopsis
envr relies on external binaries for certain functionality.
The check command reports on which binaries are available and which are not.
```
envr deps [flags]
```
### Options
```
-h, --help help for deps
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

View File

@@ -12,12 +12,13 @@ encrypt your databse. **Make 100% sure** that you have **a remote copy** of this
key somewhere, otherwise your data could be lost forever. key somewhere, otherwise your data could be lost forever.
``` ```
envr init envr init [flags]
``` ```
### Options ### Options
``` ```
-f, --force Overwrite an existing config
-h, --help help for init -h, --help help for init
``` ```

45
features.odin Normal file
View File

@@ -0,0 +1,45 @@
package main
import "core:os"
import "core:strings"
Feature :: enum {
Git,
Fd,
Age,
}
AvailableFeatures :: bit_set[Feature]
check_features :: proc() -> AvailableFeatures {
feats: AvailableFeatures
if find_binary("git") != "" {
feats += {.Git}
}
if find_binary("fd") != "" {
feats += {.Fd}
}
if find_binary("age") != "" {
feats += {.Age}
}
return feats
}
find_binary :: proc(name: string) -> string {
path_env := os.get_env("PATH", context.allocator)
paths := strings.split(path_env, ":")
for p in paths {
candidate := strings.join({strings.trim_right(p, "/"), name}, "/")
_, err := os.stat(candidate, context.allocator)
if err == nil {
return candidate
}
}
return ""
}
has_feature :: proc(feats: AvailableFeatures, f: Feature) -> bool {
return f in feats
}

30
flake.lock generated
View File

@@ -5,11 +5,11 @@
"nixpkgs-lib": "nixpkgs-lib" "nixpkgs-lib": "nixpkgs-lib"
}, },
"locked": { "locked": {
"lastModified": 1751413152, "lastModified": 1778716662,
"narHash": "sha256-Tyw1RjYEsp5scoigs1384gIg6e0GoBVjms4aXFfRssQ=", "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "77826244401ea9de6e3bac47c2db46005e1f30b5", "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -20,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1761597516, "lastModified": 1767313136,
"narHash": "sha256-wxX7u6D2rpkJLWkZ2E932SIvDJW8+ON/0Yy8+a5vsDU=", "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "daf6dc47aa4b44791372d6139ab7b25269184d55", "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -36,11 +36,11 @@
}, },
"nixpkgs-lib": { "nixpkgs-lib": {
"locked": { "locked": {
"lastModified": 1751159883, "lastModified": 1777168982,
"narHash": "sha256-urW/Ylk9FIfvXfliA1ywh75yszAbiTEVgpPeinFyVZo=", "narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixpkgs.lib", "repo": "nixpkgs.lib",
"rev": "14a40a1d7fb9afa4739275ac642ed7301a9ba1ab", "rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -51,11 +51,11 @@
}, },
"nixpkgs-unstable": { "nixpkgs-unstable": {
"locked": { "locked": {
"lastModified": 1751949589, "lastModified": 1781173989,
"narHash": "sha256-mgFxAPLWw0Kq+C8P3dRrZrOYEQXOtKuYVlo9xvPntt8=", "narHash": "sha256-fnzKKPvS+oieI/pTzotA5tkoM47EB1NpaBcgk4R97hE=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "9b008d60392981ad674e04016d25619281550a9d", "rev": "8c91a71d13451abc40eb9dae8910f972f979852f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -80,11 +80,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1752055615, "lastModified": 1780220602,
"narHash": "sha256-19m7P4O/Aw/6+CzncWMAJu89JaKeMh3aMle1CNQSIwM=", "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "c9d477b5d5bd7f26adddd3f96cfd6a904768d4f9", "rev": "db947814a175b7ca6ded66e21383d938df01c227",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -21,7 +21,12 @@
imports = [ imports = [
inputs.treefmt-nix.flakeModule inputs.treefmt-nix.flakeModule
]; ];
systems = [ "x86_64-linux" ]; systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
];
perSystem = perSystem =
{ pkgs, system, inputs', ... }: { { pkgs, system, inputs', ... }: {
@@ -56,7 +61,7 @@
packages.default = pkgs.buildGoModule rec { packages.default = pkgs.buildGoModule rec {
pname = "envr"; pname = "envr";
version = "0.1.0"; version = "0.2.0";
src = ./.; src = ./.;
# If the build complains, uncomment this line # If the build complains, uncomment this line
# vendorHash = "sha256:0000000000000000000000000000000000000000000000000000"; # vendorHash = "sha256:0000000000000000000000000000000000000000000000000000";
@@ -92,6 +97,14 @@
gotools gotools
cobra-cli cobra-cli
age
sqlite
unstable.odin
unstable.ols
# Build tools
zip
# IDE # IDE
unstable.helix unstable.helix
typescript-language-server typescript-language-server

68
main.odin Normal file
View File

@@ -0,0 +1,68 @@
package main
import "core:fmt"
import "core:os"
GO_BINARY :: "./envr-go"
main :: proc() {
cmd, ok := parse_args()
if !ok {
return
}
if !is_implemented(cmd.name) {
fallback_to_go()
return
}
switch cmd.name {
case "version":
cmd_version(&cmd)
case "deps":
cmd_deps(&cmd)
case "list":
cmd_list(&cmd)
case "backup", "add":
cmd_backup(&cmd)
case "remove":
cmd_remove(&cmd)
case "restore":
cmd_restore(&cmd)
case "edit-config":
cmd_edit_config(&cmd)
case:
fmt.printf("Unknown command: %s\n", cmd.name)
print_usage()
os.exit(1)
}
}
fallback_to_go :: proc() {
args := make([dynamic]string)
append(&args, "./envr-go")
for i in 1..<len(os.args) {
append(&args, os.args[i])
}
desc := os.Process_Desc{
command = args[:],
stdin = os.stdin,
stdout = os.stdout,
stderr = os.stderr,
}
p, err1 := os.process_start(desc)
if err1 != nil {
fmt.printf("Error: failed to run envr-go: %v\n", err1)
os.exit(1)
}
state, err2 := os.process_wait(p)
if err2 != nil {
fmt.printf("Error waiting for envr-go: %v\n", err2)
os.exit(1)
}
os.exit(int(state.exit_code))
}

34
sqlite/sqlite.odin Normal file
View File

@@ -0,0 +1,34 @@
package sqlite
import "core:c"
foreign import lib "system:sqlite3"
OK :: 0
ROW :: 100
DONE :: 101
foreign lib {
@(link_name="sqlite3_open")
db_open :: proc(filename: cstring, ppDb: ^^rawptr) -> c.int ---
@(link_name="sqlite3_close")
db_close :: proc(db: ^rawptr) -> c.int ---
@(link_name="sqlite3_errmsg")
db_errmsg :: proc(db: ^rawptr) -> cstring ---
@(link_name="sqlite3_exec")
db_exec :: proc(db: ^rawptr, sql: cstring, callback: rawptr, callback_arg: rawptr, errmsg: ^cstring) -> c.int ---
@(link_name="sqlite3_prepare_v2")
prepare_v2 :: proc(db: ^rawptr, sql: cstring, nByte: c.int, ppStmt: ^^rawptr, pzTail: ^cstring) -> c.int ---
@(link_name="sqlite3_step")
step :: proc(stmt: ^rawptr) -> c.int ---
@(link_name="sqlite3_finalize")
finalize :: proc(stmt: ^rawptr) -> c.int ---
@(link_name="sqlite3_column_text")
column_text :: proc(stmt: ^rawptr, iCol: c.int) -> cstring ---
@(link_name="sqlite3_column_bytes")
column_bytes :: proc(stmt: ^rawptr, iCol: c.int) -> c.int ---
@(link_name="sqlite3_bind_text")
bind_text :: proc(stmt: ^rawptr, idx: c.int, val: cstring, n: c.int, destructor: rawptr) -> c.int ---
@(link_name="sqlite3_changes")
changes :: proc(db: ^rawptr) -> c.int ---
}

19
stubs.odin Normal file
View File

@@ -0,0 +1,19 @@
package main
import "core:fmt"
cmd_init :: proc(cmd: ^Command) {
fmt.println("TODO: init")
}
cmd_scan :: proc(cmd: ^Command) {
fmt.println("TODO: scan")
}
cmd_sync :: proc(cmd: ^Command) {
fmt.println("TODO: sync")
}
cmd_check :: proc(cmd: ^Command) {
fmt.println("TODO: check")
}

90
table.odin Normal file
View File

@@ -0,0 +1,90 @@
package main
import "core:fmt"
import "core:strings"
render_table :: proc(headers: []string, rows: [][]string) {
if !is_tty() {
render_json_rows(headers, rows)
return
}
col_widths := make([dynamic]int, 0, len(headers))
for i in 0..<len(headers) {
append(&col_widths, strings.rune_count(headers[i]))
}
for r in rows {
for i in 0..<len(r) {
w := strings.rune_count(r[i])
if i < len(col_widths) && w > col_widths[i] {
col_widths[i] = w
}
}
}
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
defer delete(col_widths)
hline :: proc(b: ^strings.Builder, left, mid, right: string, widths: [dynamic]int) {
strings.write_string(b, left)
for i in 0..<len(widths) {
for _ in 0..<widths[i]+2 {
strings.write_string(b, "\u2500")
}
if i < len(widths)-1 {
strings.write_string(b, mid)
} else {
strings.write_string(b, right)
}
}
fmt.println(strings.to_string(b^))
strings.builder_reset(b)
}
hline(&b, "\u250c", "\u252c", "\u2510", col_widths)
cell :: proc(b: ^strings.Builder, s: string, width: int) {
extra := len(s) - strings.rune_count(s)
fmt.sbprintf(b, " %-*s \u2502", width + extra, s)
}
strings.write_string(&b, "\u2502")
for i in 0..<len(headers) {
cell(&b, headers[i], col_widths[i])
}
fmt.println(strings.to_string(b))
strings.builder_reset(&b)
hline(&b, "\u251c", "\u253c", "\u2524", col_widths)
for r in rows {
strings.write_string(&b, "\u2502")
for i in 0..<len(r) {
cell(&b, r[i], col_widths[i])
}
fmt.println(strings.to_string(b))
strings.builder_reset(&b)
}
hline(&b, "\u2514", "\u2534", "\u2518", col_widths)
}
render_json_rows :: proc(headers: []string, rows: [][]string) {
fmt.print("[")
for i in 0..<len(rows) {
if i > 0 {
fmt.print(",")
}
fmt.print("{")
for j in 0..<len(headers) {
if j > 0 {
fmt.print(",")
}
fmt.printf("\"%s\":\"%s\"", headers[j], rows[i][j])
}
fmt.print("}")
}
fmt.println("]")
}

7
tty.odin Normal file
View File

@@ -0,0 +1,7 @@
package main
import "core:sys/posix"
is_tty :: proc() -> bool {
return bool(posix.isatty(1))
}

13
version.odin Normal file
View File

@@ -0,0 +1,13 @@
package main
import "core:fmt"
VERSION :: "0.2.0"
cmd_version :: proc(cmd: ^Command) {
if has_flag(cmd, "long") || has_flag(cmd, "l") {
fmt.printf("envr version %s\n", VERSION)
} else {
fmt.println(VERSION)
}
}