Compare commits

16 Commits

Author SHA1 Message Date
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
19 changed files with 368 additions and 174 deletions

View File

@@ -18,15 +18,17 @@ type Config struct {
ScanConfig scanConfig `json:"scan"`
}
// Used by age to encrypt and decrypt the database.
type SshKeyPair struct {
Private string `json:"private"` // Path to the private key file
Public string `json:"public"` // Path to the public key file
}
type scanConfig struct {
// TODO: Support multiple matchers
Matcher string `json:"matcher"`
Exclude string `json:"exclude"`
Include string `json:"include"`
Exclude []string `json:"exclude"`
Include []string `json:"include"`
}
// Create a fresh config with sensible defaults.
@@ -46,8 +48,13 @@ func NewConfig(privateKeyPaths []string) Config {
Keys: keys,
ScanConfig: scanConfig{
Matcher: "\\.env",
Exclude: "*.envrc",
Include: "~",
Exclude: []string{
"*\\.envrc",
"\\.local/",
"node_modules",
"vendor",
},
Include: []string{"~"},
},
}
}
@@ -107,19 +114,39 @@ func (c *Config) Save() error {
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
func (c Config) scan() (paths []string, err error) {
searchPath, err := c.searchPath()
searchPaths, err := c.searchPaths()
if err != nil {
return []string{}, err
}
for _, searchPath := range searchPaths {
// Find all files (including ignored ones)
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()
if err != nil {
return []string{}, err
return paths, err
}
allFiles := strings.Split(strings.TrimSpace(string(allOutput)), "\n")
@@ -129,7 +156,7 @@ func (c Config) scan() (paths []string, err error) {
// Find unignored files
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()
if err != nil {
return []string{}, err
@@ -154,30 +181,34 @@ func (c Config) scan() (paths []string, err error) {
}
}
return ignoredFiles, nil
paths = append(paths, ignoredFiles...)
}
func (c Config) searchPath() (path string, err error) {
include := c.ScanConfig.Include
return paths, nil
}
if include == "~" {
func (c Config) searchPaths() (paths []string, err error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return homeDir, nil
return paths, err
}
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 {
return "", err
return paths, err
}
return absPath, nil
paths = append(paths, absPath)
}
// TODO: Should this be private?
func (s SshKeyPair) Identity() (age.Identity, error) {
return paths, nil
}
func (s SshKeyPair) identity() (age.Identity, error) {
sshKey, err := os.ReadFile(s.Private)
if err != nil {
return nil, fmt.Errorf("failed to read SSH key: %w", err)
@@ -191,8 +222,7 @@ func (s SshKeyPair) Identity() (age.Identity, error) {
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)
if err != nil {
return nil, fmt.Errorf("failed to read SSH key: %w", err)

View File

@@ -13,19 +13,12 @@ import (
_ "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 {
db *sql.DB
cfg Config
features *AvailableFeatures
// If true, the database will be saved to disk before closing
changed bool
}
func Open() (*Db, error) {
@@ -37,7 +30,7 @@ func Open() (*Db, error) {
if _, err := os.Stat("/home/spencer/.envr/data.age"); err != nil {
// Create a new DB
db, err := newDb()
return &Db{db, *cfg, nil}, err
return &Db{db, *cfg, nil, true}, err
} else {
// Open the existing DB
tmpFile, err := os.CreateTemp("", "envr-*.db")
@@ -59,7 +52,7 @@ func Open() (*Db, error) {
restoreDB(tmpFile.Name(), memDb)
return &Db{memDb, *cfg, nil}, nil
return &Db{memDb, *cfg, nil, false}, nil
}
}
@@ -72,7 +65,6 @@ func newDb() (*sql.DB, error) {
} else {
_, err := db.Exec(`create table envr_env_files (
path text primary key not null
, dir text not null
, remotes text -- JSON
, sha256 text not null
, contents text not null
@@ -108,7 +100,7 @@ func decryptDb(tmpFilePath string, keys []SshKeyPair) error {
identities := make([]age.Identity, 0, len(keys))
for _, key := range keys {
id, err := key.Identity()
id, err := key.identity()
if err != nil {
return err
@@ -150,23 +142,27 @@ func restoreDB(path string, destDB *sql.DB) error {
// Returns all the EnvFiles present in the database.
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 {
return nil, err
}
defer rows.Close()
for rows.Next() {
var envFile EnvFile
var remotesJSON string
err := rows.Scan(&envFile.Path, &envFile.Dir, &remotesJSON, &envFile.Sha256, &envFile.contents)
var remotesJson []byte
for rows.Next() {
err := rows.Scan(&envFile.Path, &remotesJson, &envFile.Sha256, &envFile.contents)
if err != nil {
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)
}
@@ -178,10 +174,10 @@ func (db *Db) List() (results []EnvFile, err error) {
return results, nil
}
func (db *Db) Close(mode CloseMode) error {
func (db *Db) Close() error {
defer db.db.Close()
if mode == Write {
if db.changed {
// Create tmp file
tmpFile, err := os.CreateTemp("", "envr-*.db")
if err != nil {
@@ -197,6 +193,8 @@ func (db *Db) Close(mode CloseMode) error {
if err := encryptDb(tmpFile.Name(), db.cfg.Keys); err != nil {
return err
}
db.changed = false
}
return nil
@@ -242,7 +240,7 @@ func encryptDb(tmpFilePath string, keys []SshKeyPair) error {
recipients := make([]age.Recipient, 0, len(keys))
for _, key := range keys {
recipient, err := key.Recipient()
recipient, err := key.recipient()
if err != nil {
return err
@@ -278,14 +276,16 @@ func (db *Db) Insert(file EnvFile) error {
// Insert into database
_, err = db.db.Exec(`
INSERT OR REPLACE INTO envr_env_files (path, dir, remotes, sha256, contents)
VALUES (?, ?, ?, ?, ?)
`, file.Path, file.Dir, string(remotesJSON), file.Sha256, file.contents)
INSERT OR REPLACE INTO envr_env_files (path, remotes, sha256, contents)
VALUES (?, ?, ?, ?)
`, file.Path, string(remotesJSON), file.Sha256, file.contents)
if err != nil {
return fmt.Errorf("failed to insert env file: %w", err)
}
db.changed = true
return nil
}
@@ -293,12 +293,15 @@ func (db *Db) Insert(file EnvFile) error {
func (db *Db) Fetch(path string) (envFile EnvFile, err error) {
var remotesJSON string
row := db.db.QueryRow("SELECT path, dir, remotes, sha256, contents FROM envr_env_files WHERE path = ?", path)
err = row.Scan(&envFile.Path, &envFile.Dir, &remotesJSON, &envFile.Sha256, &envFile.contents)
row := db.db.QueryRow("SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?", path)
err = row.Scan(&envFile.Path, &remotesJSON, &envFile.Sha256, &envFile.contents)
if err != nil {
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 {
return EnvFile{}, fmt.Errorf("failed to unmarshal remotes: %w", err)
}
@@ -322,12 +325,21 @@ func (db *Db) Delete(path string) error {
return fmt.Errorf("no file found with path: %s", path)
}
db.changed = true
return nil
}
// Finds .env files in the filesystem that aren't present in the database.
func (db *Db) Scan() ([]string, error) {
all_paths, err := db.cfg.scan()
// path overrides the already configured
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 {
return []string{}, err
}

View File

@@ -11,6 +11,7 @@ import (
type EnvFile struct {
Path string
// Dir is derived from Path, and is not stored in the database.
Dir string
Remotes []string // []string
Sha256 string
@@ -95,19 +96,39 @@ func getGitRemotes(dir string) []string {
return remotes
}
// Install the file into the file system
// Install the file into the file system. If the file already exists,
// it will be overwritten.
func (file EnvFile) Restore() error {
// TODO: Handle restores more cleanly
// TODO: Duplicate work is being done when called from the Sync function.
if _, err := os.Stat(file.Path); err == nil {
// file already exists
// Read existing file and calculate its hash
existingContents, err := os.ReadFile(file.Path)
if err != nil {
return fmt.Errorf("failed to read existing file for hash comparison: %w", err)
}
hash := sha256.Sum256(existingContents)
existingSha := fmt.Sprintf("%x", hash)
if existingSha == file.Sha256 {
return fmt.Errorf("file already exists: %s", file.Path)
} else {
if err := os.WriteFile(file.Path, []byte(file.contents), 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
} else {
// file doesn't exist
// Ensure the directory exists
if _, err := os.Stat(file.Dir); err != nil {
return fmt.Errorf("directory missing")
}
// Check if file already exists
if _, err := os.Stat(file.Path); err == nil {
return fmt.Errorf("file already exists: %s", file.Path)
}
// Write the contents to the file
if err := os.WriteFile(file.Path, []byte(file.contents), 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
@@ -116,6 +137,8 @@ func (file EnvFile) Restore() error {
return nil
}
}
// Try to reconcile the EnvFile with the filesystem.
//
// If Updated is returned, [Db.Insert] should be called on file.
@@ -150,7 +173,7 @@ func (file *EnvFile) Sync() (result EnvFileSyncResult, err error) {
}
}
// Update the EnvFile using the file system
// Update the EnvFile using the file system.
func (file *EnvFile) Backup() error {
// Read the contents of the file
contents, err := os.ReadFile(file.Path)

View File

@@ -13,7 +13,7 @@ const (
// fd
Fd AvailableFeatures = 2
// All features are present
All AvailableFeatures = Git & Fd
All AvailableFeatures = Git | Fd
)
// Checks for available features.

View File

@@ -27,7 +27,7 @@ var backupCmd = &cobra.Command{
if err != nil {
return err
} else {
defer db.Close(app.Write)
defer db.Close()
record := app.NewEnvFile(path)
if err := db.Insert(record); err != nil {

View File

@@ -1,48 +1,106 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/olekukonko/tablewriter"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var checkCmd = &cobra.Command{
Use: "check",
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.`,
Use: "check [path]",
Short: "check if files in the current directory are backed up",
// TODO: Long description for new check command
Args: cobra.MaximumNArgs(1),
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()
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
} 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
if features&app.Fd == 1 {
table.Append([]string{"fd", "✓ Available"})
// Scan only the specified path for .env files
filesInPath, err = db.Scan([]string{absPath})
if err != nil {
return fmt.Errorf("failed to scan path for env files: %w", err)
}
} 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
}
},
}

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"
)
// TODO: Add --force (-f) flag.
var initCmd = &cobra.Command{
Use: "init",
DisableFlagsInUseLine: true,
Short: "Set up envr",
Long: `The init command generates your initial config and saves it to
~/.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
key somewhere, otherwise your data could be lost forever.`,
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
config, _ := app.LoadConfig()
if config != nil {
return fmt.Errorf("You have already initialized envr")
} else {
if config == nil || force {
keys, err := selectSSHKeys()
if err != nil {
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))
}
return nil
} else {
return fmt.Errorf(`You have already initialized envr.
Run again with the --force flag if you want to reinitialize.
`)
}
},
}
func init() {
initCmd.Flags().BoolP("force", "f", false, "Overwrite an existing config")
rootCmd.AddCommand(initCmd)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ var syncCmd = &cobra.Command{
if err != nil {
return err
} else {
defer db.Close(app.Write)
defer db.Close()
files, err := db.List()
if err != nil {

View File

@@ -44,7 +44,8 @@ at before, restore your backup with:
### SEE ALSO
* [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 init](envr_init.md) - Set up envr
* [envr list](envr_list.md) - View your tracked files

View File

@@ -1,15 +1,9 @@
## envr check
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.
check if files in the current directory are backed up
```
envr check [flags]
envr check [path] [flags]
```
### 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.
```
envr init
envr init [flags]
```
### Options
```
-f, --force Overwrite an existing config
-h, --help help for init
```

View File

@@ -61,7 +61,7 @@
packages.default = pkgs.buildGoModule rec {
pname = "envr";
version = "0.1.0";
version = "0.1.1";
src = ./.;
# If the build complains, uncomment this line
# vendorHash = "sha256:0000000000000000000000000000000000000000000000000000";

View File

@@ -2,7 +2,6 @@ package main
import "github.com/sbrow/envr/cmd"
// TODO: `envr check` command that looks in cwd and tells you if it's backed up or not.
func main() {
cmd.Execute()
}