Compare commits

14 Commits

40 changed files with 2332 additions and 262 deletions

2
.envrc
View File

@@ -1,3 +1 @@
use flake
export PATH="$PATH:./vendor/bin"

10
.gitignore vendored
View File

@@ -1,3 +1,9 @@
# dev env
.direnv
node_modules
vendor
# docs
man
# build artifacts
envr
result

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Spencer Reid Brower
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

133
README.md
View File

@@ -1,4 +1,133 @@
# envr - Backup your env files
Have you ever wanted to back up all your .env files in case your hard drive gets
nuked? `envr` makes it easier.
`envr` is a binary application that tracks your `.env` files
in an encyrpted sqlite database. Changes can be effortlessly synced with
`envr sync`, and restored with `envr restore`.
`envr` puts all your .env files in one safe place, so you can back them up with
the tool [of your choosing](#backup-options).
## Features
- 🔐 **Encrypted Storage**: All `.env` files are encrypted using your ssh key and
[age](https://github.com/FiloSottile/age) encryption.
- 🔄 **Automatic Sync**: Update the database with one command, which can easily
be run on a cron.
- 🔍 **Smart Scanning**: Automatically discover and import `.env` files in your
home directory.
-**Interactive CLI**: User-friendly prompts for file selection and management.
## TODOS
- [ ] `envr sync` - Restore missing .env files, and update backed up ones.
- [ ] `envr scan` - Search for missing / moved .env files.
- [ ] 🗂️ **Rename Detection**: Automatically handle renamed repositories.
- [ ] Allow use of keys from `ssh-agent`
- [x] Allow configuration of ssh key.
- [x] Allow multiple ssh keys.
## Prerequisites
- An SSH key pair (for encryption/decryption)
- The following binaries:
- [fd](https://github.com/sharkdp/fd)
- [git](https://git-scm.com)
## Installation
### With Go
If you already have `go` installed:
```bash
go install github.com/sbrow/envr
envr init
```
### With Nix
If you are a [nix](https://nixos.org/) user
#### Try it out
```bash
nix run github.com:sbrow/envr --
```
#### Install it
```nix
# /etc/nixos/configuration.nix
{ config, envr, system, ... }: {
environment.systemPackages = [
envr.packages.${system}.default
];
}
```
## Quick Start
Check out the [man page](./docs/cli/envr.md) for the quick setup guide.
## Disclaimers
> [!CAUTION]
> Do not lose your SSH key pair! Your backup will be **lost forever**.
## Commands
See [the docs](./docs/cli) for the current list of available commands.
## Configuration
The configuration file is created during initialization:
```jsonc
# Example ~/.envr/config.json
{
"keys": [
{
"private": "/home/spencer/.ssh/id_ed25519",
"public": "/home/spencer/.ssh/id_ed25519.pub"
}
],
"scan": {
"matcher": "\\.env",
"exclude": "*.envrc",
"include": "~"
}
}
```
## Backup Options
`envr` merely gathers your `.env` files in one local place. It is up to you to
back up the database (found at `~/.envr/data.age`) to a *secure* and *remote*
location.
### Git
`envr` preserves inodes when updating the database, so you can safely hardlink
`~/.envr/data.age` into your [GNU Stow](https://www.gnu.org/software/stow/),
[Home Manager](https://github.com/nix-community/home-manager), or
[NixOS](https://nixos.wiki/wiki/flakes) repository.
> [!CAUTION]
> For **maximum security**, only save your `data.age` file to a local
(i.e. non-cloud) git server that **you personally control**.
>
> I take no responsibility if you push all your secrets to a public GitHub repo.
### restic
[restic](https://restic.readthedocs.io/en/latest/010_introduction.html).
## License
This project is licensed under the [MIT License](./LICENSE).
## Support
For issues, feature requests, or questions, please
[open an issue](https://github.com/sbrow/envr/issues).

207
app/config.go Normal file
View File

@@ -0,0 +1,207 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"filippo.io/age"
"filippo.io/age/agessh"
)
type Config struct {
Keys []SshKeyPair `json:"keys"`
ScanConfig scanConfig `json:"scan"`
}
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 {
Matcher string `json:"matcher"`
Exclude string `json:"exclude"`
Include string `json:"include"`
}
// Create a fresh config with sensible defaults.
func NewConfig(privateKeyPaths []string) Config {
var keys = []SshKeyPair{}
for _, priv := range privateKeyPaths {
var key = SshKeyPair{
Private: priv,
Public: priv + ".pub",
}
keys = append(keys, key)
}
return Config{
Keys: keys,
ScanConfig: scanConfig{
Matcher: "\\.env",
Exclude: "*.envrc",
Include: "~",
},
}
}
// Read the Config from disk.
func LoadConfig() (*Config, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
configPath := filepath.Join(homeDir, ".envr", "config.json")
data, err := os.ReadFile(configPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("No config file found. Please run `envr init` to generate one.")
} else {
return nil, err
}
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
// Write the Config to disk.
func (c *Config) Save() error {
// Create the ~/.envr directory
homeDir, err := os.UserHomeDir()
if err != nil {
return err
}
configDir := filepath.Join(homeDir, ".envr")
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
}
configPath := filepath.Join(configDir, "config.json")
// Check if file exists and is not empty
if info, err := os.Stat(configPath); err == nil {
if info.Size() > 0 {
return os.ErrExist
}
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0644)
}
// 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()
if err != nil {
return []string{}, err
}
// 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)
allOutput, err := allCmd.Output()
if err != nil {
return []string{}, err
}
allFiles := strings.Split(strings.TrimSpace(string(allOutput)), "\n")
if len(allFiles) == 1 && allFiles[0] == "" {
allFiles = []string{}
}
// 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)
unignoredOutput, err := unignoredCmd.Output()
if err != nil {
return []string{}, err
}
unignoredFiles := strings.Split(strings.TrimSpace(string(unignoredOutput)), "\n")
if len(unignoredFiles) == 1 && unignoredFiles[0] == "" {
unignoredFiles = []string{}
}
// Create a map for faster lookup
unignoredMap := make(map[string]bool)
for _, file := range unignoredFiles {
unignoredMap[file] = true
}
// Filter to get only ignored files
var ignoredFiles []string
for _, file := range allFiles {
if !unignoredMap[file] {
ignoredFiles = append(ignoredFiles, file)
}
}
return ignoredFiles, nil
}
func (c Config) searchPath() (path string, err error) {
include := c.ScanConfig.Include
if include == "~" {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return homeDir, nil
}
absPath, err := filepath.Abs(include)
if err != nil {
return "", err
}
return absPath, nil
}
// TODO: Should this be private?
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)
}
id, err := agessh.ParseIdentity(sshKey)
if err != nil {
return nil, fmt.Errorf("failed to parse SSH identity: %w", err)
}
return id, nil
}
// TODO: Should this be private?
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)
}
id, err := agessh.ParseRecipient(string(sshKey))
if err != nil {
return nil, fmt.Errorf("failed to parse SSH identity: %w", err)
}
return id, nil
}

376
app/db.go Normal file
View File

@@ -0,0 +1,376 @@
package app
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"filippo.io/age"
_ "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
}
func Open() (*Db, error) {
cfg, err := LoadConfig()
if err != nil {
return nil, err
}
if _, err := os.Stat("/home/spencer/.envr/data.age"); err != nil {
// Create a new DB
db, err := newDb()
return &Db{db, *cfg, nil}, err
} else {
// Open the existing DB
tmpFile, err := os.CreateTemp("", "envr-*.db")
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
defer os.Remove(tmpFile.Name())
err = decryptDb(tmpFile.Name(), (*cfg).Keys)
if err != nil {
return nil, fmt.Errorf("failed to decrypt database: %w", err)
}
memDb, err := newDb()
if err != nil {
return nil, fmt.Errorf("failed to open temp database: %w", err)
}
restoreDB(tmpFile.Name(), memDb)
return &Db{memDb, *cfg, nil}, nil
}
}
// Creates the database for the first time
func newDb() (*sql.DB, error) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
return nil, err
} 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
);`)
if err != nil {
return nil, err
} else {
return db, err
}
}
}
// Decrypt the database from the age file into a temp sqlite file.
func decryptDb(tmpFilePath string, keys []SshKeyPair) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
tmpFile, err := os.OpenFile(tmpFilePath, os.O_WRONLY, 0)
if err != nil {
return fmt.Errorf("failed to open temp file: %w", err)
}
defer tmpFile.Close()
ageFilePath := filepath.Join(homeDir, ".envr", "data.age")
ageFile, err := os.Open(ageFilePath)
if err != nil {
return fmt.Errorf("failed to open age file: %w", err)
}
defer ageFile.Close()
identities := make([]age.Identity, 0, len(keys))
for _, key := range keys {
id, err := key.Identity()
if err != nil {
return err
}
identities = append(identities, id)
}
reader, err := age.Decrypt(ageFile, identities[:]...)
if err != nil {
return fmt.Errorf("failed to decrypt age file: %w", err)
}
_, err = io.Copy(tmpFile, reader)
if err != nil {
return fmt.Errorf("failed to copy decrypted content: %w", err)
}
return nil
}
// Restore the database from a file into memory
func restoreDB(path string, destDB *sql.DB) error {
// Attach the source database
_, err := destDB.Exec("ATTACH DATABASE ? AS source", path)
if err != nil {
return fmt.Errorf("failed to attach database: %w", err)
}
defer destDB.Exec("DETACH DATABASE source")
// Copy data from source to destination
_, err = destDB.Exec("INSERT INTO main.envr_env_files SELECT * FROM source.envr_env_files")
if err != nil {
return fmt.Errorf("failed to copy data: %w", err)
}
return nil
}
// 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")
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)
if err != nil {
return nil, err
}
// TODO: unmarshal remotesJSON into envFile.remotes
results = append(results, envFile)
}
if err = rows.Err(); err != nil {
return nil, err
}
return results, nil
}
func (db *Db) Close(mode CloseMode) error {
defer db.db.Close()
if mode == Write {
// Create tmp file
tmpFile, err := os.CreateTemp("", "envr-*.db")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
defer os.Remove(tmpFile.Name())
if err := backupDb(db.db, tmpFile.Name()); err != nil {
return err
}
if err := encryptDb(tmpFile.Name(), db.cfg.Keys); err != nil {
return err
}
}
return nil
}
// Save the in-memory database to a tmp file.
func backupDb(memDb *sql.DB, tmpFilePath string) error {
_, err := memDb.Exec("VACUUM INTO ?", tmpFilePath)
if err != nil {
return fmt.Errorf("failed to vacuum database to file: %w", err)
}
return nil
}
// Encrypt the database from the temp sqlite file into an age file.
func encryptDb(tmpFilePath string, keys []SshKeyPair) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
ageFilePath := filepath.Join(homeDir, ".envr", "data.age")
// Ensure .envr directory exists
err = os.MkdirAll(filepath.Dir(ageFilePath), 0755)
if err != nil {
return fmt.Errorf("failed to create .envr directory: %w", err)
}
// Open temp file for reading
tmpFile, err := os.Open(tmpFilePath)
if err != nil {
return fmt.Errorf("failed to open temp file: %w", err)
}
defer tmpFile.Close()
// Open/create age file for writing (this preserves hardlinks)
ageFile, err := os.OpenFile(ageFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to open age file: %w", err)
}
defer ageFile.Close()
recipients := make([]age.Recipient, 0, len(keys))
for _, key := range keys {
recipient, err := key.Recipient()
if err != nil {
return err
}
recipients = append(recipients, recipient)
}
writer, err := age.Encrypt(ageFile, recipients...)
if err != nil {
return fmt.Errorf("failed to create age writer: %w", err)
}
_, err = io.Copy(writer, tmpFile)
if err != nil {
return fmt.Errorf("failed to encrypt and write data: %w", err)
}
err = writer.Close()
if err != nil {
return fmt.Errorf("failed to close age writer: %w", err)
}
return nil
}
func (db *Db) Insert(file EnvFile) error {
// Marshal remotes to JSON
remotesJSON, err := json.Marshal(file.Remotes)
if err != nil {
return fmt.Errorf("failed to marshal remotes: %w", err)
}
// 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)
if err != nil {
return fmt.Errorf("failed to insert env file: %w", err)
}
return nil
}
// Select a single EnvFile from the database.
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)
if err != nil {
return EnvFile{}, fmt.Errorf("failed to fetch env file: %w", err)
}
if err = json.Unmarshal([]byte(remotesJSON), &envFile.Remotes); err != nil {
return EnvFile{}, fmt.Errorf("failed to unmarshal remotes: %w", err)
}
return envFile, nil
}
// Removes a file from the database, if present.
func (db *Db) Delete(path string) error {
result, err := db.db.Exec("DELETE FROM envr_env_files WHERE path = ?", path)
if err != nil {
return fmt.Errorf("failed to delete env file: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("no file found with path: %s", path)
}
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()
if err != nil {
return []string{}, err
}
untracked_paths := make([]string, 0, len(all_paths)/2)
env_files, err := db.List()
if err != nil {
return untracked_paths, err
}
for _, path := range all_paths {
backed_up := slices.ContainsFunc(env_files, func(e EnvFile) bool {
return e.Path == path
})
if backed_up {
continue
} else {
untracked_paths = append(untracked_paths, path)
}
}
return untracked_paths, nil
}
// Determine the available features on the installed system.
func (db *Db) Features() AvailableFeatures {
if db.features == nil {
feats := checkFeatures()
db.features = &feats
}
return *db.features
}
// Returns nil if [Db.Scan] is safe to use, null otherwise.
func (db *Db) CanScan() error {
if db.Features()&Fd == 0 {
return fmt.Errorf(
"please install fd to use the scan function (https://github.com/sharkdp/fd)",
)
} else {
return nil
}
}

169
app/env_file.go Normal file
View File

@@ -0,0 +1,169 @@
package app
import (
"crypto/sha256"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
type EnvFile struct {
Path string
Dir string
Remotes []string // []string
Sha256 string
contents string
}
// The result returned by [EnvFile.Sync]
type EnvFileSyncResult int
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
// no further action is required.
Noop
)
func NewEnvFile(path string) EnvFile {
// Get absolute path and directory
absPath, err := filepath.Abs(path)
if err != nil {
panic(fmt.Errorf("failed to get absolute path: %w", err))
}
dir := filepath.Dir(absPath)
// Get git remotes
remotes := getGitRemotes(dir)
// Read the file contents
contents, err := os.ReadFile(path)
if err != nil {
panic(fmt.Errorf("failed to read file %s: %w", path, err))
}
// Calculate SHA256 hash
hash := sha256.Sum256(contents)
sha256Hash := fmt.Sprintf("%x", hash)
return EnvFile{
Path: absPath,
Dir: dir,
Remotes: remotes,
Sha256: sha256Hash,
contents: string(contents),
}
}
func getGitRemotes(dir string) []string {
// TODO: Check for Git flag and change behaviour if unset.
cmd := exec.Command("git", "remote", "-v")
cmd.Dir = dir
output, err := cmd.Output()
if err != nil {
// Not a git repository or git command failed
return []string{}
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
remoteSet := make(map[string]bool)
for _, line := range lines {
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) >= 2 {
remoteSet[parts[1]] = true
}
}
remotes := make([]string, 0, len(remoteSet))
for remote := range remoteSet {
remotes = append(remotes, remote)
}
return remotes
}
// Install the file into the file system
func (file EnvFile) Restore() error {
// TODO: Handle restores more cleanly
// Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(file.Path), 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
// 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)
}
return nil
}
// Try to reconcile the EnvFile with the filesystem.
//
// If Updated is returned, [Db.Insert] should be called on file.
func (file *EnvFile) Sync() (result EnvFileSyncResult, err error) {
// Check if the path exists in the file system
_, 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
hash := sha256.Sum256(contents)
currentSha := fmt.Sprintf("%x", hash)
if file.Sha256 == currentSha {
// Nothing to do
return Noop, nil
} 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
func (file *EnvFile) Backup() error {
// Read the contents of the file
contents, err := os.ReadFile(file.Path)
if err != nil {
return fmt.Errorf("failed to read file %s: %w", file.Path, err)
}
// Update file.contents to match
file.contents = string(contents)
// Update file.sha256
hash := sha256.Sum256(contents)
file.Sha256 = fmt.Sprintf("%x", hash)
return nil
}

32
app/features.go Normal file
View File

@@ -0,0 +1,32 @@
package app
import (
"os/exec"
)
// Represents which binaries are present in $PATH.
// Used to fail safely when required features are unavailable
type AvailableFeatures int
const (
Git AvailableFeatures = 1
// fd
Fd AvailableFeatures = 2
// All features are present
All AvailableFeatures = Git & Fd
)
// Checks for available features.
func checkFeatures() (feats AvailableFeatures) {
// Check for git binary
if _, err := exec.LookPath("git"); err == nil {
feats |= Git
}
// Check for fd binary
if _, err := exec.LookPath("fd"); err == nil {
feats |= Fd
}
return feats
}

55
cmd/backup.go Normal file
View File

@@ -0,0 +1,55 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"strings"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
// backupCmd represents the backup command
var backupCmd = &cobra.Command{
Use: "backup <path>",
Short: "Import a .env file into envr",
Args: cobra.ExactArgs(1),
// Long: `Long desc`
RunE: func(cmd *cobra.Command, args []string) error {
path := args[0]
if len(strings.TrimSpace(path)) == 0 {
return fmt.Errorf("No path provided")
}
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close(app.Write)
record := app.NewEnvFile(path)
if err := db.Insert(record); err != nil {
panic(err)
} else {
fmt.Printf("Saved %s into the database", path)
return nil
}
}
},
}
func init() {
rootCmd.AddCommand(backupCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// backupCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// backupCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

51
cmd/check.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 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.`,
RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open()
if 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"})
} else {
table.Append([]string{"fd", "✗ Missing"})
}
table.Render()
return nil
}
},
}
func init() {
rootCmd.AddCommand(checkCmd)
}

55
cmd/edit_config.go Normal file
View File

@@ -0,0 +1,55 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/spf13/cobra"
)
var editConfigCmd = &cobra.Command{
Use: "edit-config",
Short: "Edit your config with your default editor",
// Long: ``,
Run: func(cmd *cobra.Command, args []string) {
editor := os.Getenv("EDITOR")
if editor == "" {
fmt.Println("Error: $EDITOR environment variable is not set")
return
}
homeDir, err := os.UserHomeDir()
if err != nil {
fmt.Printf("Error getting home directory: %v\n", err)
return
}
configPath := filepath.Join(homeDir, ".envr", "config.json")
// Check if config file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
fmt.Printf("Config file does not exist at %s. Run 'envr init' first.\n", configPath)
return
}
// Execute the editor
execCmd := exec.Command(editor, configPath)
execCmd.Stdin = os.Stdin
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
if err := execCmd.Run(); err != nil {
fmt.Printf("Error running editor: %v\n", err)
return
}
},
}
func init() {
rootCmd.AddCommand(editConfigCmd)
}

95
cmd/init.go Normal file
View File

@@ -0,0 +1,95 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/sbrow/envr/app"
"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.
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 {
config, _ := app.LoadConfig()
if config != nil {
return fmt.Errorf("You have already initialized envr")
} else {
keys, err := selectSSHKeys()
if err != nil {
return fmt.Errorf("Error selecting SSH keys: %v", err)
}
if len(keys) == 0 {
return fmt.Errorf("No SSH keys selected - Config not created")
}
cfg := app.NewConfig(keys)
if err := cfg.Save(); err != nil {
return err
}
fmt.Printf("Config initialized with %d SSH key(s). You are ready to use envr.\n", len(keys))
}
return nil
},
}
func init() {
rootCmd.AddCommand(initCmd)
}
func selectSSHKeys() ([]string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
// TODO: Support reading from ssh-agent
sshDir := filepath.Join(homeDir, ".ssh")
entries, err := os.ReadDir(sshDir)
if err != nil {
return nil, fmt.Errorf("could not read ~/.ssh directory: %w", err)
}
var privateKeys []string
for _, entry := range entries {
name := entry.Name()
if !entry.IsDir() && !strings.HasSuffix(name, ".pub") &&
!strings.Contains(name, "known_hosts") && !strings.Contains(name, "config") {
privateKeys = append(privateKeys, filepath.Join(sshDir, name))
}
}
if len(privateKeys) == 0 {
return nil, fmt.Errorf("no SSH private keys found in ~/.ssh")
}
var selected []string
prompt := &survey.MultiSelect{
Message: "Select SSH private keys:",
Options: privateKeys,
}
err = survey.AskOne(prompt, &selected)
if err != nil {
return nil, err
}
return selected, nil
}

69
cmd/list.go Normal file
View File

@@ -0,0 +1,69 @@
package cmd
import (
"encoding/json"
"os"
"path/filepath"
"github.com/mattn/go-isatty"
"github.com/olekukonko/tablewriter"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
type listEntry struct {
Directory string `json:"directory"`
Path string `json:"path"`
}
var listCmd = &cobra.Command{
Use: "list",
Short: "View your tracked files",
RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open()
if err != nil {
return err
}
defer db.Close(app.ReadOnly)
rows, err := db.List()
if err != nil {
return err
}
if isatty.IsTerminal(os.Stdout.Fd()) {
table := tablewriter.NewWriter(os.Stdout)
table.Header([]string{"Directory", "Path"})
for _, row := range rows {
path, err := filepath.Rel(row.Dir, row.Path)
if err != nil {
return err
}
table.Append([]string{row.Dir + "/", path})
}
table.Render()
} else {
var entries []listEntry
for _, row := range rows {
path, err := filepath.Rel(row.Dir, row.Path)
if err != nil {
return err
}
entries = append(entries, listEntry{
Directory: row.Dir + "/",
Path: path,
})
}
encoder := json.NewEncoder(os.Stdout)
return encoder.Encode(entries)
}
return nil
},
}
func init() {
rootCmd.AddCommand(listCmd)
}

79
cmd/mod.nu Normal file
View File

@@ -0,0 +1,79 @@
# envr command extern definitions for Nushell
# A tool for managing environment files and backups
export def tracked-paths [] {
(
^envr list
| from json
| each {
[$in.directory $in.path] | path join
}
)
}
export def untracked-paths [] {
(
^envr scan
| from json
)
}
# Complete shell types for completion command
def shells [] {
["bash", "zsh", "fish", "powershell"]
}
export extern envr [
...args: any
--help(-h) # Show help information
--toggle(-t) # Help message for toggle
]
export extern "envr backup" [
--help(-h) # Show help for backup command
path: path@untracked-paths # Path to .env file to backup
]
#TODO: envr backup path.
export extern "envr check" [
--help(-h) # Show help for check command
]
export extern "envr completion" [
shell: string@shells # Shell to generate completion for
--help(-h) # Show help for completion command
]
export extern "envr edit-config" [
--help(-h) # Show help for edit-config command
]
export extern "envr help" [
command?: string # Show help for specific command
]
export extern "envr init" [
--help(-h) # Show help for init command
]
export extern "envr list" [
--help(-h) # Show help for list command
]
export extern "envr remove" [
--help(-h) # Show help for remove command
path: path@tracked-paths
]
export extern "envr restore" [
--help(-h) # Show help for restore command
path: path@tracked-paths
]
export extern "envr scan" [
--help(-h) # Show help for scan command
]
export extern "envr sync" [
--help(-h) # Show help for sync command
]

26
cmd/nushell_completion.go Normal file
View File

@@ -0,0 +1,26 @@
package cmd
import (
_ "embed"
"fmt"
"github.com/spf13/cobra"
)
//go:embed mod.nu
var completion string
// nushellCompletionCmd represents the nushellCompletion command
var nushellCompletionCmd = &cobra.Command{
Use: "nushell-completion",
Short: "Generate custom completions for nushell",
Long: `At time of writing, cobra does not natively support nushell,
so a custom command had to be written`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Println(completion)
},
}
func init() {
rootCmd.AddCommand(nushellCompletionCmd)
}

51
cmd/remove.go Normal file
View File

@@ -0,0 +1,51 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"strings"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var removeCmd = &cobra.Command{
Use: "remove",
Short: "Remove a .env file from your database",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := args[0]
if len(strings.TrimSpace(path)) == 0 {
return fmt.Errorf("No path provided")
}
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close(app.Write)
if err := db.Delete(path); err != nil {
return err
} else {
fmt.Printf("Removed %s from the database", path)
return nil
}
}
},
}
func init() {
rootCmd.AddCommand(removeCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// removeCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// removeCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

60
cmd/restore.go Normal file
View File

@@ -0,0 +1,60 @@
/*
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
*/
package cmd
import (
"fmt"
"strings"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
// restoreCmd represents the restore command
var restoreCmd = &cobra.Command{
Use: "restore",
Short: "Install a .env file from the database into your file system",
// Long: ``,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := args[0]
if len(strings.TrimSpace(path)) == 0 {
return fmt.Errorf("No path provided")
}
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close(app.ReadOnly)
record, err := db.Fetch(path)
if err != nil {
return err
} else {
err := record.Restore()
if err != nil {
return err
} else {
return nil
}
}
}
},
}
func init() {
rootCmd.AddCommand(restoreCmd)
// Here you will define your flags and configuration settings.
// Cobra supports Persistent Flags which will work for this command
// and all subcommands, e.g.:
// restoreCmd.PersistentFlags().String("foo", "", "A help for foo")
// Cobra supports local flags which will only run when this command
// is called directly, e.g.:
// restoreCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

66
cmd/root.go Normal file
View File

@@ -0,0 +1,66 @@
package cmd
import (
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "envr",
Short: "Manage your .env files.",
Long: `envr keeps your .env synced to a local, age encrypted database.
Is a safe and eay way to gather all your .env files in one place where they can
easily be backed by another tool such as restic or git.
All your data is stored in ~/data.age
Getting started is easy:
1. Create your configuration file and set up encrypted storage:
> envr init
2. Scan for existing .env files:
> envr scan
Select the files you want to back up from the interactive list.
3. Verify that it worked:
> envr list
4. After changing any of your .env files, update the backup with:
> envr sync
5. If you lose a repository, after re-cloning the repo into the same path it was
at before, restore your backup with:
> envr restore ~/&lt;path to repository&gt;/.env`,
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.envr.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
// rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
// Expose the root command for our generators.
func Root() *cobra.Command { return rootCmd }

104
cmd/scan.go Normal file
View File

@@ -0,0 +1,104 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"github.com/AlecAivazis/survey/v2"
"github.com/mattn/go-isatty"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var scanCmd = &cobra.Command{
Use: "scan",
Short: "Find and select .env files for backup",
RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open()
if err != nil {
return err
}
if db == nil {
return fmt.Errorf("No db was loaded")
}
if err := db.CanScan(); err != nil {
return err
}
files, err := db.Scan()
if err != nil {
return err
}
if len(files) == 0 {
return fmt.Errorf("No .env files found to add.")
}
if isatty.IsTerminal(os.Stdout.Fd()) {
selectedFiles, err := selectEnvFiles(files)
if err != nil {
return err
}
// Insert selected files into database
var addedCount int
for _, file := range selectedFiles {
envFile := app.NewEnvFile(file)
err := db.Insert(envFile)
if err != nil {
fmt.Printf("Error adding %s: %v\n", file, err)
} else {
addedCount++
}
}
// Close database with write mode to persist changes
if addedCount > 0 {
err = db.Close(app.Write)
if err != nil {
return fmt.Errorf("Error saving changes: %v\n", err)
} else {
fmt.Printf("Successfully added %d file(s) to backup.\n", addedCount)
return nil
}
} else {
err = db.Close(app.ReadOnly)
if err != nil {
return fmt.Errorf("Error closing database: %v\n", err)
}
fmt.Println("No files were added.")
return nil
}
} else {
output, err := json.Marshal(files)
if err != nil {
return fmt.Errorf("Error marshaling files to JSON: %v", err)
}
fmt.Println(string(output))
return nil
}
},
}
func init() {
rootCmd.AddCommand(scanCmd)
}
func selectEnvFiles(files []string) ([]string, error) {
var selectedFiles []string
prompt := &survey.MultiSelect{
Message: "Select .env files to backup:",
Options: files,
}
err := survey.AskOne(prompt, &selectedFiles)
if err != nil {
return nil, err
}
return selectedFiles, nil
}

62
cmd/sync.go Normal file
View File

@@ -0,0 +1,62 @@
package cmd
import (
"fmt"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
// TODO: Detect when file paths have moved and update accordingly.
var syncCmd = &cobra.Command{
Use: "sync",
Short: "Update or restore your env backups",
RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close(app.Write)
files, err := db.List()
if err != nil {
return err
} else {
for _, file := range files {
fmt.Printf("%s\n", file.Path)
// Syncronize the filesystem with the database.
changed, err := file.Sync()
switch changed {
case app.Updated:
fmt.Printf("File updated - changes saved\n")
if err := db.Insert(file); err != nil {
return err
}
case app.Restored:
fmt.Printf("File missing - restored backup\n")
case app.Error:
if err == nil {
panic("err cannot be nil when Sync returns Error")
} else {
fmt.Printf("%s\n", err)
}
case app.Noop:
fmt.Println("Nothing to do")
default:
panic("Unknown result")
}
fmt.Println("")
}
return nil
}
}
},
}
func init() {
rootCmd.AddCommand(syncCmd)
}

35
cmd/version.go Normal file
View File

@@ -0,0 +1,35 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var (
version = "dev"
commit = "none"
date = "unknown"
)
var long bool
// versionCmd represents the version command
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show envr's version",
Run: func(cmd *cobra.Command, args []string) {
if long {
fmt.Printf("envr version %s\n", version)
fmt.Printf("commit: %s\n", commit)
fmt.Printf("built: %s\n", date)
} else {
fmt.Printf("%s\n", version)
}
},
}
func init() {
versionCmd.Flags().BoolVarP(&long, "long", "l", false, "Show all version information")
rootCmd.AddCommand(versionCmd)
}

57
docs/cli/envr.md Normal file
View File

@@ -0,0 +1,57 @@
## envr
Manage your .env files.
### Synopsis
envr keeps your .env synced to a local, age encrypted database.
Is a safe and eay way to gather all your .env files in one place where they can
easily be backed by another tool such as restic or git.
All your data is stored in ~/data.age
Getting started is easy:
1. Create your configuration file and set up encrypted storage:
> envr init
2. Scan for existing .env files:
> envr scan
Select the files you want to back up from the interactive list.
3. Verify that it worked:
> envr list
4. After changing any of your .env files, update the backup with:
> envr sync
5. If you lose a repository, after re-cloning the repo into the same path it was
at before, restore your backup with:
> envr restore ~/&lt;path to repository&gt;/.env
### Options
```
-h, --help help for envr
```
### SEE ALSO
* [envr backup](envr_backup.md) - Import a .env file into envr
* [envr check](envr_check.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
* [envr nushell-completion](envr_nushell-completion.md) - Generate custom completions for nushell
* [envr remove](envr_remove.md) - Remove a .env file from your database
* [envr restore](envr_restore.md) - Install a .env file from the database into your file system
* [envr scan](envr_scan.md) - Find and select .env files for backup
* [envr sync](envr_sync.md) - Update or restore your env backups
* [envr version](envr_version.md) - Show envr's version

18
docs/cli/envr_backup.md Normal file
View File

@@ -0,0 +1,18 @@
## envr backup
Import a .env file into envr
```
envr backup <path> [flags]
```
### Options
```
-h, --help help for backup
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

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

@@ -0,0 +1,24 @@
## 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.
```
envr check [flags]
```
### Options
```
-h, --help help for check
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

View File

@@ -0,0 +1,18 @@
## envr edit-config
Edit your config with your default editor
```
envr edit-config [flags]
```
### Options
```
-h, --help help for edit-config
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

27
docs/cli/envr_init.md Normal file
View File

@@ -0,0 +1,27 @@
## envr init
Set up envr
### Synopsis
The init command generates your initial config and saves it to
~/.envr/config in JSON format.
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.
```
envr init
```
### Options
```
-h, --help help for init
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

18
docs/cli/envr_list.md Normal file
View File

@@ -0,0 +1,18 @@
## envr list
View your tracked files
```
envr list [flags]
```
### Options
```
-h, --help help for list
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

View File

@@ -0,0 +1,23 @@
## envr nushell-completion
Generate custom completions for nushell
### Synopsis
At time of writing, cobra does not natively support nushell,
so a custom command had to be written
```
envr nushell-completion [flags]
```
### Options
```
-h, --help help for nushell-completion
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

18
docs/cli/envr_remove.md Normal file
View File

@@ -0,0 +1,18 @@
## envr remove
Remove a .env file from your database
```
envr remove [flags]
```
### Options
```
-h, --help help for remove
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

18
docs/cli/envr_restore.md Normal file
View File

@@ -0,0 +1,18 @@
## envr restore
Install a .env file from the database into your file system
```
envr restore [flags]
```
### Options
```
-h, --help help for restore
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

18
docs/cli/envr_scan.md Normal file
View File

@@ -0,0 +1,18 @@
## envr scan
Find and select .env files for backup
```
envr scan [flags]
```
### Options
```
-h, --help help for scan
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

18
docs/cli/envr_sync.md Normal file
View File

@@ -0,0 +1,18 @@
## envr sync
Update or restore your env backups
```
envr sync [flags]
```
### Options
```
-h, --help help for sync
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

19
docs/cli/envr_version.md Normal file
View File

@@ -0,0 +1,19 @@
## envr version
Show envr's version
```
envr version [flags]
```
### Options
```
-h, --help help for version
-l, --long Show all version information
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.

52
flake.lock generated
View File

@@ -5,11 +5,11 @@
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1760948891,
"narHash": "sha256-TmWcdiUUaWk8J4lpjzu4gCGxWY6/Ok7mOK4fIFfBuU4=",
"lastModified": 1751413152,
"narHash": "sha256-Tyw1RjYEsp5scoigs1384gIg6e0GoBVjms4aXFfRssQ=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "864599284fc7c0ba6357ed89ed5e2cd5040f0c04",
"rev": "77826244401ea9de6e3bac47c2db46005e1f30b5",
"type": "github"
},
"original": {
@@ -20,25 +20,27 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1761594641,
"narHash": "sha256-sImk6SJQASDLQo8l+0zWWaBgg7TueLS6lTvdH5pBZpo=",
"lastModified": 1761597516,
"narHash": "sha256-wxX7u6D2rpkJLWkZ2E932SIvDJW8+ON/0Yy8+a5vsDU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1666250dbe4141e4ca8aaf89b40a3a51c2e36144",
"rev": "daf6dc47aa4b44791372d6139ab7b25269184d55",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1754788789,
"narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=",
"lastModified": 1751159883,
"narHash": "sha256-urW/Ylk9FIfvXfliA1ywh75yszAbiTEVgpPeinFyVZo=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "a73b9c743612e4244d865a2fdee11865283c04e6",
"rev": "14a40a1d7fb9afa4739275ac642ed7301a9ba1ab",
"type": "github"
},
"original": {
@@ -49,11 +51,11 @@
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1761594641,
"narHash": "sha256-sImk6SJQASDLQo8l+0zWWaBgg7TueLS6lTvdH5pBZpo=",
"lastModified": 1751949589,
"narHash": "sha256-mgFxAPLWw0Kq+C8P3dRrZrOYEQXOtKuYVlo9xvPntt8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1666250dbe4141e4ca8aaf89b40a3a51c2e36144",
"rev": "9b008d60392981ad674e04016d25619281550a9d",
"type": "github"
},
"original": {
@@ -63,27 +65,11 @@
"type": "github"
}
},
"process-compose-flake": {
"locked": {
"lastModified": 1761063998,
"narHash": "sha256-l14CiQZM2ZpWp6leugJ5/GKU1aydT/xrvyKRMgYc0ak=",
"owner": "Platonic-Systems",
"repo": "process-compose-flake",
"rev": "d1839830cd4a814830a27b3cb58437306747bbd3",
"type": "github"
},
"original": {
"owner": "Platonic-Systems",
"repo": "process-compose-flake",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"nixpkgs-unstable": "nixpkgs-unstable",
"process-compose-flake": "process-compose-flake",
"treefmt-nix": "treefmt-nix"
}
},
@@ -94,11 +80,11 @@
]
},
"locked": {
"lastModified": 1761311587,
"narHash": "sha256-Msq86cR5SjozQGCnC6H8C+0cD4rnx91BPltZ9KK613Y=",
"lastModified": 1752055615,
"narHash": "sha256-19m7P4O/Aw/6+CzncWMAJu89JaKeMh3aMle1CNQSIwM=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "2eddae033e4e74bf581c2d1dfa101f9033dbd2dc",
"rev": "c9d477b5d5bd7f26adddd3f96cfd6a904768d4f9",
"type": "github"
},
"original": {

View File

@@ -1,28 +1,25 @@
{
description = "A dev environment";
description = "Manage your .env files.";
inputs = {
# nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
process-compose-flake.url = "github:Platonic-Systems/process-compose-flake";
treefmt-nix.url = "github:numtide/treefmt-nix";
treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =
inputs@{ self
, flake-parts
inputs@{ flake-parts
, nixpkgs
, nixpkgs-unstable
, process-compose-flake
, self
, treefmt-nix
}:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [
inputs.treefmt-nix.flakeModule
# inputs.process-compose-flake.flakeModule
];
systems = [ "x86_64-linux" ];
@@ -33,7 +30,7 @@
config.allowUnfree = true;
overlays = [
(final: prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; })
(_final: _prev: { unstable = inputs'.nixpkgs-unstable.legacyPackages; })
];
};
@@ -51,27 +48,49 @@
# Format nix files
programs.nixpkgs-fmt.enable = true;
programs.deadnix.enable = true;
# programs.deadnix.enable = true;
# Format js, json, and yaml files
programs.prettier.enable = true;
settings.formatter.prettier =
{
excludes = [
"public/**"
"resources/js/modernizr.js"
"storage/app/caniuse.json"
"*.md"
];
};
# Format go files
programs.goimports.enable = true;
};
packages.default = pkgs.buildGoModule rec {
pname = "envr";
version = "0.1.0";
src = ./.;
# If the build complains, uncomment this line
# vendorHash = "sha256:0000000000000000000000000000000000000000000000000000";
vendorHash = "sha256-aC82an6vYifewx4amfXLzk639jz9fF5bD5cF6krY0Ks=";
nativeBuildInputs = [ pkgs.installShellFiles ];
ldflags = [
"-X github.com/sbrow/envr/cmd.version=v${version}"
# "-X github.com/sbrow/envr/cmd.commit=$(git rev-parse HEAD)"
# "-X github.com/sbrow/envr/cmd.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
];
postBuild = ''
# Generate man pages
$GOPATH/bin/docgen -out ./man -format man
'';
postInstall = ''
# Install man pages
installManPage ./man/*.1
'';
};
devShells.default = pkgs.mkShell
{
buildInputs = with pkgs; [
age
fd
nushell
sqlite
go
gopls
gotools
cobra-cli
# IDE
unstable.helix

41
go.mod Normal file
View File

@@ -0,0 +1,41 @@
module github.com/sbrow/envr
go 1.24.6
require (
filippo.io/age v1.2.1
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/mattn/go-isatty v0.0.20
github.com/olekukonko/tablewriter v1.1.0
github.com/spf13/cobra v1.10.1
modernc.org/sqlite v1.39.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.0.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/crypto v0.24.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

138
go.sum Normal file
View File

@@ -0,0 +1,138 @@
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -0,0 +1,58 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/sbrow/envr/cmd" // update to your module path
"github.com/spf13/cobra/doc"
)
func main() {
out := flag.String("out", "./docs/cli", "output directory")
format := flag.String("format", "markdown", "markdown|man|rest")
front := flag.Bool("frontmatter", false, "prepend simple YAML front matter to markdown")
flag.Parse()
if err := os.MkdirAll(*out, 0o755); err != nil {
log.Fatal(err)
}
root := cmd.Root()
root.DisableAutoGenTag = true // stable, reproducible files (no timestamp footer)
switch *format {
case "markdown":
if *front {
prep := func(filename string) string {
base := filepath.Base(filename)
name := strings.TrimSuffix(base, filepath.Ext(base))
title := strings.ReplaceAll(name, "_", " ")
return fmt.Sprintf("---\ntitle: %q\nslug: %q\ndescription: \"CLI reference for %s\"\n---\n\n", title, name, title)
}
link := func(name string) string { return strings.ToLower(name) }
if err := doc.GenMarkdownTreeCustom(root, *out, prep, link); err != nil {
log.Fatal(err)
}
} else {
if err := doc.GenMarkdownTree(root, *out); err != nil {
log.Fatal(err)
}
}
case "man":
hdr := &doc.GenManHeader{Title: strings.ToUpper(root.Name()), Section: "1"}
if err := doc.GenManTree(root, hdr, *out); err != nil {
log.Fatal(err)
}
case "rest":
if err := doc.GenReSTTree(root, *out); err != nil {
log.Fatal(err)
}
default:
log.Fatalf("unknown format: %s", *format)
}
}

7
main.go Normal file
View File

@@ -0,0 +1,7 @@
package main
import "github.com/sbrow/envr/cmd"
func main() {
cmd.Execute()
}

201
mod.nu
View File

@@ -1,201 +0,0 @@
#!/usr/bin/env nu
use std assert;
# Manage your .env files with ease
export def envr [] {
help envr
}
# Import a .env file into envr
export def "envr backup" [
file: path
] {
cd (dirname $file);
let contents = (open $file --raw)
open db
let row = {
path: $file
dir: (pwd)
remotes: (git remote | lines | each { git remote get-url $in } | to json)
sha256: ($contents | hash sha256)
contents: $contents
};
try {
$row | stor insert -t envr_env_files
} catch {
$row | stor update -t envr_env_files -w $'path == "($row.path)"'
}
close db
$"file '($file)' backed up!"
}
const db_path = '~/.envr/data.age'
# Create or load the database
def "open db" [] {
if (not ($db_path | path exists)) {
create-db
} else {
# Open the db
let dec = mktemp -p ~/.envr;
let priv_key = ((envr config show).priv_key | path expand);
age -d -i $priv_key ($db_path | path expand) | save -f $dec
stor import -f $dec
rm $dec
}
stor open
}
def "create-db" []: nothing -> any {
let dec = mktemp -p ~/.envr;
sqlite3 $dec '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
);'
let pub_key = ((envr config show).pub_key | path expand);
age -R $pub_key $dec | save -f $db_path
stor import -f $dec
rm $dec;
}
def "close db" [] {
let dec = mktemp -p ~/.envr;
stor export --file-name $dec;
# Encrypt the file
let pub_key = ((envr config show).pub_key | path expand);
age -R $pub_key $dec | save -f $db_path
rm $dec
}
# Restore a .env file from backup.
export def "envr restore" [
path?: path # The path of the file to restore. Will be prompted if left blank.
]: nothing -> string {
let files = (files)
let $path = if ($path | is-empty) {
(
$files
| select path dir remotes
| input list -f "Please select a file to restore"
| get path
)
} else {
$path
}
let file = ($files | where path == $path | first);
assert ($file | is-not-empty) "File must be found"
let response = if (($path | path type) == 'file') {
if (open --raw $file.path | hash sha256 | $in == $file.sha256) {
# File matches
$'(ansi yellow)file is already up to date.(ansi reset)';
} else {
# File exists, but doesn't match
let continue = (
[No Yes]
| input list $"File '($path)' already exists, are you sure you want to overwrite it?"
| $in == 'Yes'
);
if ($continue) {
null
} else {
$'(ansi yellow)No action was taken(ansi reset)'
}
}
};
if ($response | is-empty) {
# File should be restored
$file.contents | save -f $path
return $'(ansi green)($path) restored!(ansi reset)'
} else {
return $response
}
}
# Supported config formats
const available_formats = [
json
toml
yaml
ini
xml
nuon
]
# Create your initial config
export def "envr config init" [
format?: string
#identity?: path
] {
mkdir ~/.envr
let format = if ($format | is-empty) {
$available_formats | input list 'Please select the desired format for your config file'
}
let identity = '~/.ssh/id_ed25519';
# The path to the config file.
let source = $'~/.envr/config.($format)'
{
source: $source
priv_key: $identity
pub_key: $'($identity).pub'
} | tee {
save $source
}
}
# View your tracked files
export def "envr list" [] {
(files | reject contents)
}
# List all the files in the database
def files [] {
(
open db
| query db 'select * from envr_env_files'
| update remotes { from json }
)
}
# Update your env backups
export def "envr sync" [] {
'TODO:'
}
# Edit your config
export def "envr config edit" [] {
^$env.EDITOR (config-file)
}
def "config-file" []: [nothing -> path nothing -> nothing] {
ls ~/.envr/config.* | get 0.name -o
}
# show your current config
export def "envr config show" []: nothing -> record<source: path, priv_key: path, pub_key: path> {
open (config-file)
}