refactor: Removed go code.

This commit is contained in:
2026-06-12 16:35:34 -04:00
parent 0a74b0dbcc
commit b1b0449b7b
21 changed files with 0 additions and 2115 deletions

View File

@@ -1,267 +0,0 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"filippo.io/age"
"filippo.io/age/agessh"
)
type Config struct {
Keys []SshKeyPair `json:"keys"`
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"`
}
// 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: []string{
"*\\.envrc",
"\\.local",
"node_modules",
"vendor",
},
Include: []string{"~"},
},
}
}
// 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)
}
// 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) {
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", c.buildFdArgs(searchPath, true)...)
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{}
}
// Find unignored files
fmt.Printf("Search for unignored fies in \"%s\"...\n", searchPath)
unignoredCmd := exec.Command("fd", c.buildFdArgs(searchPath, false)...)
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)
}
}
paths = append(paths, ignoredFiles...)
}
return paths, nil
}
func (c Config) searchPaths() (paths []string, err error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return paths, err
}
includes := c.ScanConfig.Include
for _, include := range includes {
path := strings.Replace(include, "~", homeDir, 1)
absPath, err := filepath.Abs(path)
if err != nil {
return paths, err
}
paths = append(paths, absPath)
}
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)
}
id, err := agessh.ParseIdentity(sshKey)
if err != nil {
return nil, fmt.Errorf("failed to parse SSH identity: %w", err)
}
return id, nil
}
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
}
// 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
}

421
app/db.go
View File

@@ -1,421 +0,0 @@
package app
// TODO: app/db.go should be reviewed.
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"filippo.io/age"
_ "modernc.org/sqlite"
)
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) {
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, true}, 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, false}, 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
, 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 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 []byte
err := rows.Scan(&envFile.Path, &remotesJson, &envFile.Sha256, &envFile.contents)
if err != nil {
return nil, err
}
// 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)
}
if err = rows.Err(); err != nil {
return nil, err
}
return results, nil
}
func (db *Db) Close() error {
defer db.db.Close()
if db.changed {
// 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
}
db.changed = false
}
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, 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
}
// 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, 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)
}
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)
}
db.changed = true
return nil
}
// Finds .env files in the filesystem that aren't present in the database.
// 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
}
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
}
}
// 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

@@ -1,244 +0,0 @@
package app
import (
"crypto/sha256"
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
)
type EnvFile struct {
// TODO: Should use FileName in the struct and derive from the path.
Path string
// Dir is derived from Path, and is not stored in the database.
Dir string
Remotes []string // []string
Sha256 string
contents string
}
// The result returned by [EnvFile.Sync]
type EnvFileSyncResult int
const (
// The filesystem contents matches the struct
// no further action is required.
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 {
// 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
}
// Reconcile the state of the database with the state of the filesystem, using
// dir to determine which side to use a the source of truth.
func (f *EnvFile) sync(dir syncDirection, db *Db) (result EnvFileSyncResult, err error) {
if result != Noop {
panic("Invalid state")
}
if _, err := os.Stat(f.Dir); err != nil {
// Directory doesn't exist
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")
}
}
}
if _, err := os.Stat(f.Path); err != nil {
if errors.Is(err, os.ErrNotExist) {
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
} 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.
//
// If Updated is returned, [Db.Insert] should be called on file.
func (file *EnvFile) Sync() (result EnvFileSyncResult, err error) {
return file.sync(TrustFilesystem, nil)
}
// Install the file into the file system. If the file already exists,
// it will be overwritten.
func (file EnvFile) Restore() error {
_, err := file.sync(TrustDatabase, nil)
return err
}
// 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
}

View File

@@ -1,60 +0,0 @@
package app
import (
"fmt"
"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.
// 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
}
// 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}
}
}

View File

@@ -1,56 +0,0 @@
/*
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",
Aliases: []string{"add"},
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()
record := app.NewEnvFile(path)
if err := db.Insert(record); err != nil {
return 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")
}

View File

@@ -1,109 +0,0 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var checkCmd = &cobra.Command{
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
}
// 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 {
// Path is a file, just check this specific file
filesInPath = []string{absPath}
}
// 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
},
}
func init() {
rootCmd.AddCommand(checkCmd)
}

View File

@@ -1,51 +0,0 @@
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

@@ -1,55 +0,0 @@
/*
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)
}

View File

@@ -1,96 +0,0 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var initCmd = &cobra.Command{
Use: "init",
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 {
force, _ := cmd.Flags().GetBool("force")
config, _ := app.LoadConfig()
if config == nil || force {
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
} 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)
}
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
}

View File

@@ -1,69 +0,0 @@
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()
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)
}

View File

@@ -1,26 +0,0 @@
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)
}

View File

@@ -1,51 +0,0 @@
/*
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()
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")
}

View File

@@ -1,60 +0,0 @@
/*
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()
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")
}

View File

@@ -1,66 +0,0 @@
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 }

View File

@@ -1,104 +0,0 @@
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(nil)
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()
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()
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
}

View File

@@ -1,101 +0,0 @@
package cmd
import (
"encoding/json"
"os"
"github.com/mattn/go-isatty"
"github.com/olekukonko/tablewriter"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
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()
files, err := db.List()
if err != nil {
return err
} else {
type syncResult struct {
Path string `json:"path"`
Status string `json:"status"`
}
var results []syncResult
for _, file := range files {
// Syncronize the filesystem with the database.
oldPath := file.Path
changed, err := db.Sync(&file)
var status string
switch changed {
case app.BackedUp:
status = "Backed Up"
if err := db.Insert(file); err != nil {
return err
}
case app.Restored:
fallthrough
case app.RestoredAndDirUpdated:
status = "Restored"
case app.Error:
if err == nil {
panic("err cannot be nil when Sync returns Error")
}
status = err.Error()
case app.Noop:
status = "OK"
case app.DirUpdated:
status = "Moved"
default:
panic("Unknown result")
}
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
}
}
},
}
func init() {
rootCmd.AddCommand(syncCmd)
}

View File

@@ -1,35 +0,0 @@
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)
}

41
go.mod
View File

@@ -1,41 +0,0 @@
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
View File

@@ -1,138 +0,0 @@
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

@@ -1,58 +0,0 @@
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)
}
}

View File

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