24 Commits

Author SHA1 Message Date
spencer cec8d0e490 feat: init command. 2026-06-16 12:00:42 -04:00
spencer c663f15618 fix: Fixed issue with buffer size. 2026-06-16 12:00:42 -04:00
spencer f3a0dd842e refactor: Moved deps into root.zig. 2026-06-16 12:00:42 -04:00
spencer f15d2e4297 feat: Added table viewer. 2026-06-16 12:00:42 -04:00
spencer aedbeb28bd feat: list cmd. 2026-06-16 12:00:42 -04:00
spencer 336bd37613 feat: Restore db from file. 2026-06-16 12:00:42 -04:00
spencer d00055aa3e feat(config): Added data path. 2026-06-16 12:00:42 -04:00
spencer 256382ce11 feat: accept config in Db 2026-06-16 12:00:41 -04:00
spencer 3277102b19 feat(age): accept multiple recipients. 2026-06-16 12:00:41 -04:00
spencer 20d4ad5c28 feat: Implemented basic db operation. 2026-06-16 12:00:41 -04:00
spencer 3fd3ca2b10 feat: Created own age wrapper. 2026-06-16 12:00:41 -04:00
spencer 0db97b714f feat: Added age-ffi. 2026-06-16 12:00:41 -04:00
spencer 41944af80c feat: zig-sqlite. 2026-06-16 12:00:41 -04:00
spencer 8d1b0ffa2d feat: added Config parsing. 2026-06-16 12:00:41 -04:00
spencer ccaeda8f8f feat(comma): Added help method. 2026-06-16 12:00:40 -04:00
spencer 61ab6925b8 docs: Added AI Disclaimer to README.md. 2026-06-16 12:00:40 -04:00
spencer b4952a4b2d feat: Added help output for commands. 2026-06-16 12:00:40 -04:00
spencer 33ca6525e2 feat(comma): Added enum value for unknown commands. 2026-06-16 12:00:40 -04:00
spencer 89ac19b246 feat: Migrated deps command. 2026-06-16 12:00:40 -04:00
spencer 5a3bc605c7 refactor: Broke comma into a separate package. 2026-06-16 12:00:40 -04:00
spencer a0cbaaddf1 feat: Added command structure. 2026-06-16 12:00:39 -04:00
spencer 898a919145 wip: feat: Migrated version command to zig. 2026-06-16 12:00:39 -04:00
spencer a7be0c719d feat: Created zig wrapper. 2026-06-16 12:00:39 -04:00
spencer 09df5639a7 feat: Added zig config. 2026-06-16 12:00:39 -04:00
189 changed files with 36624 additions and 7191 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
use flake use flake
export PATH="./:$PATH" ROOT="/home/spencer/github.com/envr-zig"
export PATH=".:${ROOT}/deps/zig:${ROOT}/deps/zls:$PATH"
+28
View File
@@ -0,0 +1,28 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24.6'
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...
-32
View File
@@ -1,32 +0,0 @@
name: Odin
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libsodium-dev sqlite3 libsqlite3-dev libsodium-dev
- name: Install Odin
run: |
git clone https://github.com/odin-lang/Odin.git /opt/odin
cd /opt/odin
./build_odin.sh release
echo "/opt/odin" >> "$GITHUB_PATH"
- name: Build
run: |
odin build . -o:speed -out:envr
- name: Test
run: odin test .
+2 -4
View File
@@ -2,7 +2,6 @@ on:
push: push:
branches: branches:
- main - main
- prerelease
permissions: permissions:
contents: write contents: write
@@ -15,7 +14,7 @@ jobs:
release-please: release-please:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: googleapis/release-please-action@v5 - uses: googleapis/release-please-action@v4
with: with:
# this assumes that you have created a personal access token # this assumes that you have created a personal access token
# (PAT) and configured it as a GitHub action secret named # (PAT) and configured it as a GitHub action secret named
@@ -23,5 +22,4 @@ jobs:
token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }} token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }}
# this is a built-in strategy in release-please, see "Action Inputs" # this is a built-in strategy in release-please, see "Action Inputs"
# for more options # for more options
release-type: simple release-type: go
target-branch: ${{ github.ref_name }}
+6 -8
View File
@@ -1,20 +1,18 @@
# dev env # dev env
.direnv .direnv
/.env
list.json # dependencies
deps
vendor
# docs # docs
man man
# build artifacts # build artifacts
*.spall .zig-cache
builds builds
envr envr
envr-go envr-go
envr-prof
findr/findr
findr/findr-prof
findr/bench-*.md
gendocs
result result
version.odin zig-pkg
-86
View File
@@ -1,91 +1,5 @@
# Changelog # Changelog
## [1.0.0](https://github.com/sbrow/envr/compare/v0.4.0...v1.0.0) (2026-07-27)
### ⚠ BREAKING CHANGES
* Renamed `nushell-completion` command to `completion nushell`.
* Databases with a JSON formatted remotes column will no longer parse correctly. If you have an old database, make sure you upgrade to envr 0.4.0 and run `envr list` before upgrading to this version.
### Features
* Added `uninstall` command. ([222a0a3](https://github.com/sbrow/envr/commit/222a0a3452b978d9bd14d6d2b91e40cd8f4959e3))
* Added bash completion support. ([e0a339e](https://github.com/sbrow/envr/commit/e0a339e8ec996c0e91e27357854abd9a78bd695a))
* Brought back the docs generator. ([86a4da6](https://github.com/sbrow/envr/commit/86a4da6cbaae698379c73b8345e6d238b2a11a46))
* Improved nushell wrapper output. ([209ec89](https://github.com/sbrow/envr/commit/209ec89addbed4718c5aed845ab20b386d1e425d))
* Scan results can now be filtered during selection. ([ae42d53](https://github.com/sbrow/envr/commit/ae42d53f029dd185143a645fb3f3a787051c1bb4))
### Bug Fixes
* Removed irrelevent flags from `nushell-completion` command help text. ([af1860c](https://github.com/sbrow/envr/commit/af1860c970604470992182bbfe9ecb15609a0acf))
### Performance Improvements
* Added `#no_bounds_check` to ssh parsers. ([f5624e9](https://github.com/sbrow/envr/commit/f5624e91f1236eecb263f28600ce7f4b2fda149f))
* Added a pre-alloc opportunity. ([997f11b](https://github.com/sbrow/envr/commit/997f11b257f1c981f73c6ae97d6e821147b5e2fc))
* Removed support for JSON formatted remotes. ([5282a44](https://github.com/sbrow/envr/commit/5282a4414b9a0211604553f2cb9434025aa5296b))
### Code Refactoring
* Renamed `nushell-completion` command to `completion nushell`. ([b0635e0](https://github.com/sbrow/envr/commit/b0635e035c44fb20f9b02265cff1ed8db30aa9d0))
## [0.4.0](https://github.com/sbrow/envr/compare/v0.3.0...v0.4.0) (2026-06-29)
### Features
* Added `--color` flag. ([c3e667e](https://github.com/sbrow/envr/commit/c3e667e7bca660b21b5851460691ec8bd5026f80))
* Added `--format`, `-f` flag. ([bb84c56](https://github.com/sbrow/envr/commit/bb84c56c98bead9ce9bad3f89fb3b61b53d05a10))
* Colorized console output. ([33cd7c4](https://github.com/sbrow/envr/commit/33cd7c4eda43287fe3dc2d2289a4e1531b524ef6))
* Removed runtime git dependency. ([12574e1](https://github.com/sbrow/envr/commit/12574e123bdedba3aca813143e906ec5e0b95719))
### Bug Fixes
* -h short flag now works on subcommands. ([2b68617](https://github.com/sbrow/envr/commit/2b68617b5d6c17444b04307d7b4b7f4a3aefb978))
* Databases errors are less likely to go unnoticed. ([f825bc2](https://github.com/sbrow/envr/commit/f825bc2b096632bb258d5681b1072941e9d4233d))
* Fixed leaks. ([c7c254f](https://github.com/sbrow/envr/commit/c7c254f6f2ac871182f3fcc233766884753e2049))
* Fixed memory leaks in the db. ([5059572](https://github.com/sbrow/envr/commit/5059572951b3ec20b3d2027032a9c3be5cb14dba))
* Fixed some leaks in `backup` and `scan`. ([dc72ff5](https://github.com/sbrow/envr/commit/dc72ff56fd0e165930771682b2fd266eef3e7e16))
* Fixed vet errors. ([1562fb3](https://github.com/sbrow/envr/commit/1562fb3665b8704dcffd4944047f7984b308e52e))
* Flags in help text are now customized per command. ([fb90342](https://github.com/sbrow/envr/commit/fb903421265baa65adbbe092f6bf797639f1fb1f))
* Handled mk_dir error. ([de1594d](https://github.com/sbrow/envr/commit/de1594d9d1fe46dd9ebc3be01fc5a5ebaf4064e5))
* **scan:** Fixed a bug preventing TUI from working. ([0083e4e](https://github.com/sbrow/envr/commit/0083e4e0dbc5b949a3cf4cd8b64af4f698ed9d33))
* Used os path separator rather than '/' where appropriate. ([5cc7973](https://github.com/sbrow/envr/commit/5cc79737753f0f19db2bc55304ea5f70237cbffd))
### Performance Improvements
* Improved the performance of table rendering. ([0b5bf4d](https://github.com/sbrow/envr/commit/0b5bf4db73113bdd43d8a225ec0ae53fedd25918))
* remotes are now stored as a newline delimited list. ([96b3d63](https://github.com/sbrow/envr/commit/96b3d6340a95b67c884ba940b46461e51989fcbb))
* Replaced `fd` with custom internals. ([2ef733f](https://github.com/sbrow/envr/commit/2ef733fe58594b0a0b6e3ef85142b74af445ccb8))
## [0.3.0](https://github.com/sbrow/envr/compare/v0.2.1...v0.3.0) (2026-06-16)
Version 0.3.0 represents a significant departure (and improvement) for envr.
The entire codebase was rewritten in [Odin](https://odin-lang.org/) (from Go).
This reduced the binary size from over 17MB to under 600k, improved performance,
and significantly reduced the number of project dependencies from 69 to just 2.
### ⚠ BREAKING CHANGES
* The encryption format of databases has changed. Age encryption is no longer supported, and no automatic migration path was implemented.
### Features
* All encryption/decryption now happens in-memory. ([fe2b256](https://github.com/sbrow/envr/commit/fe2b256bd61eaf551d53faf3893b473a64a94667))
* Config can be loaded from any path with `--config-file (-c)` flag. ([4a26ee8](https://github.com/sbrow/envr/commit/4a26ee814591e6aab0eb99d2359d51b31011edfe))
* Switched from age to libsodium. ([23b8c2d](https://github.com/sbrow/envr/commit/23b8c2dc671a23cf76cf6746b33806ded9381486))
### Performance Improvements
* Improved writer performance. ([365e914](https://github.com/sbrow/envr/commit/365e9149b1a738ac9119bb5f74dc7e047ecfed5b))
## [0.2.1](https://github.com/sbrow/envr/compare/v0.2.0...v0.2.1) (2026-01-12) ## [0.2.1](https://github.com/sbrow/envr/compare/v0.2.0...v0.2.1) (2026-01-12)
+17 -24
View File
@@ -2,15 +2,16 @@
# Builds release artifacts for GitHub releases # Builds release artifacts for GitHub releases
APP_NAME := envr APP_NAME := envr
VERSION := $(file < version.txt) VERSION := $(shell grep 'version = ' flake.nix | head -1 | sed 's/.*version = "\(.*\)";/\1/')
BUILD_DIR := builds BUILD_DIR := builds
LDFLAGS := -X github.com/sbrow/envr/cmd.version=v$(VERSION) -s -w
# Binary names # Binary names
LINUX_AMD64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64 LINUX_AMD64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64
LINUX_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64 LINUX_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64
DARWIN_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64 DARWIN_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64
.PHONY: all clean cleanall build-linux build-darwin compress release profile help .PHONY: all clean cleanall build-linux build-darwin compress release help
# Default target # Default target
all: release clean all: release clean
@@ -22,23 +23,23 @@ $(BUILD_DIR):
# Build Linux AMD64 # Build Linux AMD64
$(LINUX_AMD64_BIN): $(BUILD_DIR) $(LINUX_AMD64_BIN): $(BUILD_DIR)
@echo "Building for Linux AMD64..." @echo "Building for Linux AMD64..."
odin build . -target:linux_amd64 -o:speed -out:$(LINUX_AMD64_BIN) GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(LINUX_AMD64_BIN) .
@echo "Built $(LINUX_AMD64_BIN)" @echo "Built $(LINUX_AMD64_BIN)"
# Build Linux ARM64 # Build Linux ARM64
$(LINUX_ARM64_BIN): $(BUILD_DIR) $(LINUX_ARM64_BIN): $(BUILD_DIR)
@echo "Building for Linux ARM64..." @echo "Building for Linux ARM64..."
odin build . -target:linux_arm64 -o:speed -out:$(LINUX_ARM64_BIN) GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(LINUX_ARM64_BIN) .
@echo "Built $(LINUX_ARM64_BIN)" @echo "Built $(LINUX_ARM64_BIN)"
# Build Darwin ARM64 (Mac) # Build Darwin ARM64 (Mac)
$(DARWIN_ARM64_BIN): $(BUILD_DIR) $(DARWIN_ARM64_BIN): $(BUILD_DIR)
@echo "Building for Darwin ARM64..." @echo "Building for Darwin ARM64..."
odin build . -target:darwin_arm64 -o:speed -out:$(DARWIN_ARM64_BIN) GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(DARWIN_ARM64_BIN) .
@echo "Built $(DARWIN_ARM64_BIN)" @echo "Built $(DARWIN_ARM64_BIN)"
# Build all binaries # Build all binaries
build-linux: $(LINUX_AMD64_BIN) # $(LINUX_ARM64_BIN) build-linux: $(LINUX_AMD64_BIN) $(LINUX_ARM64_BIN)
build-darwin: $(DARWIN_ARM64_BIN) build-darwin: $(DARWIN_ARM64_BIN)
# Compress Linux artifacts with gzip # Compress Linux artifacts with gzip
@@ -57,21 +58,14 @@ $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64.zip: $(DARWIN_ARM64_BIN)
# Compress all artifacts # Compress all artifacts
compress: $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64.tar.gz \ compress: $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64.tar.gz \
# $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64.tar.gz \ $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64.tar.gz \
# $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64.zip $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64.zip
# Build and compress all release artifacts # Build and compress all release artifacts
# release: build-linux build-darwin compress release: build-linux build-darwin compress
release: build-linux compress
@echo "Release artifacts created:" @echo "Release artifacts created:"
@ls -la $(BUILD_DIR)/*.tar.gz $(BUILD_DIR)/*.zip 2>/dev/null || echo "No compressed artifacts found" @ls -la $(BUILD_DIR)/*.tar.gz $(BUILD_DIR)/*.zip 2>/dev/null || echo "No compressed artifacts found"
# Build with spall profiling instrumentation
profile:
@echo "Building with spall profiling..."
odin build . -define:SPALL=true -o:speed -out:envr-prof
@echo "Built envr-prof (run it to generate envr.spall)"
# Clean binary files only # Clean binary files only
clean: clean:
@echo "Cleaning binary files..." @echo "Cleaning binary files..."
@@ -85,15 +79,14 @@ cleanall:
# Show available targets # Show available targets
help: help:
@echo "Available targets:" @echo "Available targets:"
@echo " all - Build all release artifacts (default)" @echo " all - Build all release artifacts (default)"
@echo " release - Build and compress all release artifacts" @echo " release - Build and compress all release artifacts"
@echo " build-linux - Build Linux binaries only" @echo " build-linux - Build Linux binaries only"
@echo " build-darwin - Build Darwin binaries only" @echo " build-darwin - Build Darwin binaries only"
@echo " compress - Compress all built binaries" @echo " compress - Compress all built binaries"
@echo " profile - Build with spall profiling instrumentation" @echo " clean - Remove binary files only"
@echo " clean - Remove binary files only" @echo " cleanall - Remove entire build directory"
@echo " cleanall - Remove entire build directory" @echo " help - Show this help message"
@echo " help - Show this help message"
@echo "" @echo ""
@echo "Release artifacts will be created in $(BUILD_DIR)/" @echo "Release artifacts will be created in $(BUILD_DIR)/"
@echo "Version: $(VERSION)" @echo "Version: $(VERSION)"
+34 -39
View File
@@ -3,6 +3,10 @@
Have you ever wanted to back up all your .env files in case your hard drive gets Have you ever wanted to back up all your .env files in case your hard drive gets
nuked? `envr` makes it easier. nuked? `envr` makes it easier.
> [!CAUTION]
> The Zig community is quite anti-AI. Please read the [AI Disclaimer](#ai-disclaimer)
> before wasting your time.
`envr` is a binary application that tracks your `.env` files `envr` is a binary application that tracks your `.env` files
in an encyrpted sqlite database. Changes can be effortlessly synced with in an encyrpted sqlite database. Changes can be effortlessly synced with
`envr sync`, and restored with `envr restore`. `envr sync`, and restored with `envr restore`.
@@ -10,17 +14,16 @@ in an encyrpted sqlite database. Changes can be effortlessly synced with
`envr` puts all your .env files in one safe place, so you can back them up with `envr` puts all your .env files in one safe place, so you can back them up with
the tool [of your choosing](#backup-options). the tool [of your choosing](#backup-options).
Please read the [AI Disclosure](#ai-disclosure) if you're concerned about AI code.
## Features ## Features
- **Encrypted Storage**: All `.env` files are encrypted using your ssh key and - 🔐 **Encrypted Storage**: All `.env` files are encrypted using your ssh key and
[libsodium](https://github.com/jedisct1/libsodium). [age](https://github.com/FiloSottile/age) encryption.
- **Automatic Sync**: Update the database with one command, which can easily - 🔄 **Automatic Sync**: Update the database with one command, which can easily
be run on a cron. be run on a cron.
- **Smart Scanning**: Automatically discover and import `.env` files in your - 🔍 **Smart Scanning**: Automatically discover and import `.env` files in your
home directory. home directory.
- **Rename Detection**: Automatically find and updates renamed/moved - **Interactive CLI**: User-friendly prompts for file selection and management.
- 🗂️ **Rename Detection**: Automatically finds and updates renamed/moved
repositories. repositories.
## TODOS ## TODOS
@@ -29,18 +32,21 @@ repositories.
- [x] Allow configuration of ssh key. - [x] Allow configuration of ssh key.
- [x] Allow multiple ssh keys. - [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 ## Installation
You will need an SSH key pair for encryption and decryption. You can generate one ### With Go
with `ssh-keygen -t ed25519`. It will be saved to `~/.ssh/id_ed25519`.
### With Odin If you already have `go` installed:
If you already have `odin` installed:
```bash ```bash
# You'll need libsodium and sqlite go install github.com/sbrow/envr
odin build -o:speed
envr init envr init
``` ```
@@ -93,12 +99,7 @@ The configuration file is created during initialization:
], ],
"scan": { "scan": {
"matcher": "\\.env", "matcher": "\\.env",
"exclude": [ "exclude": "*.envrc",
"*\\.envrc",
"\\.local/",
"node_modules",
"vendor"
],
"include": "~" "include": "~"
} }
} }
@@ -107,18 +108,18 @@ The configuration file is created during initialization:
## Backup Options ## Backup Options
`envr` merely gathers your `.env` files in one local place. It is up to you to `envr` merely gathers your `.env` files in one local place. It is up to you to
back up the database (found at `~/.envr/data.envr`) to a *secure* and *remote* back up the database (found at `~/.envr/data.age`) to a *secure* and *remote*
location. location.
### Git ### Git
`envr` preserves inodes when updating the database, so you can safely hardlink `envr` preserves inodes when updating the database, so you can safely hardlink
`~/.envr/data.envr` into your [GNU Stow](https://www.gnu.org/software/stow/), `~/.envr/data.age` into your [GNU Stow](https://www.gnu.org/software/stow/),
[Home Manager](https://github.com/nix-community/home-manager), or [Home Manager](https://github.com/nix-community/home-manager), or
[NixOS](https://nixos.wiki/wiki/flakes) repository. [NixOS](https://nixos.wiki/wiki/flakes) repository.
> [!CAUTION] > [!CAUTION]
> For **maximum security**, only save your `data.envr` file to a local > For **maximum security**, only save your `data.age` file to a local
(i.e. non-cloud) git server that **you personally control**. (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. > I take no responsibility if you push all your secrets to a public GitHub repo.
@@ -127,22 +128,6 @@ location.
[restic](https://restic.readthedocs.io/en/latest/010_introduction.html). [restic](https://restic.readthedocs.io/en/latest/010_introduction.html).
## AI Disclosure
This project was largely built by AI. The original project (written in go) was
written almost entirely with AI, using libraries that I requested (i.e. cobra).
I wrote it at work because I needed the tool and wanted it fast. The Odin version
was also primarily written by AI, but I stepped in a lot more, and took care to
architect the project in the way that I wanted, and made sure tests were written
to catch leaks.
Most of the documentation was written by hand, but I think the original `README.md`
was at least fleshed out by AI.
It is by no means perfect, but I have spent a lot of time doing code review and
testing to make sure the program (and documentation) meet my personal standards.
## License ## License
This project is licensed under the [MIT License](./LICENSE). This project is licensed under the [MIT License](./LICENSE).
@@ -151,3 +136,13 @@ This project is licensed under the [MIT License](./LICENSE).
For issues, feature requests, or questions, please For issues, feature requests, or questions, please
[open an issue](https://github.com/sbrow/envr/issues). [open an issue](https://github.com/sbrow/envr/issues).
## AI Disclaimer
Unless noted here, you can be assured that I have personally written and reviewed
every line of code in this software.
- Many compiler errors that couldn't be solved with a quick google search were
solved by passing errors to AI and transcribing the suggestions.
- The "Pre-Zig" version of this readme was written by AI and then edited by me.
- The Go code was mostly written using opencode, and manually tested by me.
-63
View File
@@ -1,63 +0,0 @@
# Test Coverage Plan
## Current State
- 104 tests, all passing
- Strong coverage: crypto, ssh, db CRUD + env_file + update_dir, config save/load + paths, scan, features, cant_scan, parse_args, `-c`/`--config-file` flag
- Misleading test files: `cmd_check_test`, `cmd_list_test`, `cmd_nushell_completion_test` don't test their namesake procs
- Biggest remaining gap: all `cmd_*` handlers untested
## Command handler tests
Stdout will be captured by redirecting `os.stdout` to a pipe.
### `cmd_version` (cmd_version.odin)
- Test default output (prints VERSION)
### `cmd_list` (cmd_list.odin)
- Test TTY path: fixture DB with rows, capture table output
- Test non-TTY path: capture JSON output, unmarshal and verify keys/values
- Test empty DB: verify clean output (empty table or `[]`)
### `cmd_backup` (cmd_backup.odin)
- Test successful backup: valid path, verify `db_insert` called
- Test missing file: verify error message
- Test duplicate backup: verify rejection or update behavior
### `cmd_remove` (cmd_remove.odin)
- Test successful removal: existing entry, verify `db_delete` called
- Test removal of non-existent entry: verify error or no-op
### `cmd_restore` (cmd_restore.odin)
- Test successful restore: entry exists in DB, verify file written to correct path
- Test restore of missing entry: verify error
- Test directory creation: restore to path with missing parent dirs
## Hard to test (interactive / external deps)
### `cmd_scan` (cmd_scan.odin)
- Test with fixture git repo containing `.env` files
- Test `find_unbacked` integration (already partially tested in `cmd_check_test.odin`)
- Non-TTY JSON output path
### `cmd_edit_config` (cmd_edit_config.odin)
- Needs refactoring: extract `$EDITOR` parsing into testable helper (TODO 12)
- Test multi-word editor values (`"code -w"`)
- Test missing `$EDITOR`
### `cmd_init` (cmd_init.odin)
- Interactive prompt makes this hard
- Needs refactoring: extract SSH key discovery and config generation into testable procs
- Test `--force` flag behavior
### `prompt.odin`
- Needs refactoring to be testable
- `render_options` could be tested if it accepted an `io.Writer`
- `read_key` could be tested with a pipe/redirect instead of raw stdin
- `multi_select` is end-to-end interactive, likely integration test only
## Notes
- DB integration tests should use in-memory SQLite (`:memory:`) where possible.
- Temp dir fixtures should follow the pattern in `scan_test.odin`.
- Tests that manipulate the `HOME` env var must use a mutex to prevent races with parallel test execution.
+59 -52
View File
@@ -1,62 +1,69 @@
# TODOs # TODO
1. Bring back windows support / cross-compilation. Note: These todos can wait until all the subcommands have been ported.
2. Commands are still leaking. (Write tests for everything first) ## HIGH
3. procedures should be ordered by use, main at the top, then in the order they are called from main. 1. [x] **table.odin:74-89** — Hand-rolled JSON output doesn't escape `"`, `\`, newlines. Reimplements `json.marshal` which is already imported in `cmd_list.odin`. Replace with `json.marshal`.
4. Test all command branches. 2. **db.odin:380-383, 405, 446**`sqlite.bind_text` return values overwritten but never checked. A failed bind means `sqlite.step` operates on unbound params.
6. Add tests for untested commands. 3. **config.odin:52-54**`os.user_home_dir` error silently ignored. If it fails, `home` is `""` and all paths become relative (`".envr"` instead of `"~/.envr"`).
7. Add purge command? 30. **cmd_sync.odin:46-50, 64-68** — Double `db_insert` when `BackedUp`: first insert on line 48, then `db_update_required` is also true for `BackedUp` so second insert runs on line 65. Redundant and wasteful.
8. Add docs for "building from source" ## MEDIUM
4. **db.odin:29-35**`make_temp_path` never calls `strings.builder_destroy`. Leaks builder buffer every call.
## Double-check AI output 5. **db.odin:324-327** — Map iteration (`remote_set`) is non-deterministic. Same file can produce different JSON on each backup, causing spurious DB diffs. Sort remotes before storing.
- [ ] cli.odin 6. **db.odin:470-473**`string_to_cstring` allocates via `strings.clone_to_cstring` and never frees. Called dozens of times across db operations.
- [ ] cli_test.odin
- [x] colors.odin 7. **db.odin:470, 462** — Both `string_to_cstring` and `cstring_to_string` ignore allocation errors. A nil cstring gets passed to SQLite (UB).
- [x] cmd_backup.odin
- [x] cmd_check.odin 8. **db.odin:135, 250** — String interpolation into SQL (`VACUUM INTO '%s'`, `ATTACH DATABASE '%s'`). Currently safe because input is controlled, but fragile.
- [ ] cmd_check_test.odin
- [x] cmd_edit_config.odin 9. **features.odin:30-41**`find_binary` uses `strings.join` instead of `filepath.join`, uses `os.stat` instead of checking executability, hardcodes `:` as PATH separator (wrong on Windows).
- [x] cmd_init.odin
- [x] cmd_list.odin 10. **cmd_restore.odin:20-30 & cmd_remove.odin:19-29** — Identical path-resolution block copy-pasted. `is_abs` guard is redundant since `filepath.abs` is a no-op on absolute paths. Extract a helper.
- [ ] cmd_list_test.odin
- [x] cmd_nushell_completion.odin 11. **cmd_restore.odin:44**`os.mkdir_all` error silently discarded. Subsequent write failure will be confusing.
- [x] cmd_nushell_completion_test.odin
- [x] cmd_remove.odin 12. **cmd_edit_config.odin:27**`$EDITOR` used as single binary name. Breaks for multi-word values like `"code -w"`. Needs `strings.fields()`.
- [x] cmd_restore.odin
- [x] cmd_scan.odin 33. **config.odin:178**`search_paths` silently ignores `os.user_home_dir` error. If home is empty, `~` isn't expanded. Same class of bug as issue 3.
- [x] cmd_sync.odin
- [x] cmd_version.odin 35. **prompt.odin:124**`make([dynamic]bool, len(options))` creates N zero-initialized elements. Works because `false` is the default, but same footgun as original issue 1. Should be `make([dynamic]bool, 0, len(options))`.
- [x] config.odin
- [ ] config_test.odin ## LOW
- [ ] crypto.odin
- [ ] crypto_test.odin 14. [x] **db.odin:338-341** — Unnecessary `strings.clone` before `filepath.dir` (which already returns a slice into the input).
- [ ] db.odin
- [ ] db_integration_test.odin 15. **db.odin:115**`json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data.
- [ ] db_test.odin
- [ ] flags.odin 16. **db.odin:352-353**`hex.encode` error ignored. `string(hex_bytes)` aliases the byte slice.
- [x] main.odin
- [ ] prompt.odin 18. **config.odin:51-60**`envr_dir` recomputes home dir on every call. Could cache.
- [x] scan.odin
- [ ] scan_test.odin 37. **cmd_sync.odin:80, cmd_list.odin:33, cmd_deps.odin:9**`make([]string, 2)` for table rows never freed. Leaks per row. Defer to memory pass.
- [ ] sodium.odin
- [x] sqlite/sqlite.odin ## REFACTOR
- [ ] ssh.odin
- [ ] ssh_test.odin 20. **cmd_list.odin** — Non-TTY branch builds `ListEntry` structs and marshals JSON separately. Now that `render_json_rows` (issue 1) accepts an `io.Writer` and uses `json.marshal`, unify both branches to use it. Note: will change JSON keys from `"directory"/"path"` to `"Directory"/"Path"`.
- [ ] table.odin
- [ ] table_test.odin 21. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
- [ ] findr/findr_test.odin
- [ ] findr/gitignore.odin 22. Replace is_tty with terminal.is_terminal
- [ ] findr/gitignore_test.odin
- [ ] findr/glob.odin 23. Add a text filter to the multi_select.
- [ ] findr/glob_test.odin
- [ ] findr/repos.odin 24. Create backup / fallback fd.
- [ ] findr/test_env.odin
- [ ] findr/walker.odin 25. Add tests for untested commands.
26. Add a global --config -c flag to use an alternate config.
27. version --long Odin only prints version; Go also prints commit hash and build date
28. 2 scan tests silently skip Low When fd isn't installed, tests pass without actually testing anything. These should use #assert to be sure that fd is in path.
+92
View File
@@ -0,0 +1,92 @@
# Windows Compatibility Guide
This document outlines Windows compatibility issues and solutions for the envr project.
## Critical Issues
### 1. Path Handling Bug (MUST FIX)
**File:** `app/env_file.go:209`
**Issue:** Uses `path.Join` instead of `filepath.Join`, which won't work correctly on Windows due to different path separators.
**Current code:**
```go
f.Path = path.Join(newDir, path.Base(f.Path))
```
**Fixed code:**
```go
f.Path = filepath.Join(newDir, filepath.Base(f.Path))
```
## External Dependencies
The application relies on external tools that need to be installed separately on Windows:
### Required Tools
1. **fd** - Fast file finder
- Install via: `winget install sharkdp.fd` or `choco install fd`
- Alternative: `scoop install fd`
2. **git** - Version control system
- Install via: `winget install Git.Git` or download from git-scm.com
- Usually already available on most development machines
## Minor Compatibility Notes
### File Permissions
- Unix file permissions (`0755`, `0644`) are used throughout the codebase
- These are safely ignored on Windows - no changes needed
### Editor Configuration
**File:** `cmd/edit_config.go:20-24`
**Issue:** Relies on `$EDITOR` environment variable which is less common on Windows.
**Current behavior:** Fails if `$EDITOR` is not set
**Recommended improvement:** Add fallback detection for Windows editors:
```go
editor := os.Getenv("EDITOR")
if editor == "" {
if runtime.GOOS == "windows" {
editor = "notepad.exe" // or "code.exe" for VS Code
} else {
fmt.Println("Error: $EDITOR environment variable is not set")
return
}
}
```
## Installation Instructions for Windows
1. Install required dependencies:
```powershell
winget install sharkdp.fd
winget install Git.Git
```
2. Fix the path handling bug in `app/env_file.go:209`
3. Build and run as normal:
```powershell
go build
.\envr.exe init
```
## Testing on Windows
After applying the critical path fix, the core functionality should work correctly on Windows. The application has been designed with cross-platform compatibility in mind, using:
- `filepath` package for path operations (mostly)
- `os.UserHomeDir()` for home directory detection
- Standard Go file operations
## Summary
- **1 critical bug** must be fixed for Windows compatibility
- **2 external tools** need to be installed
- **1 minor enhancement** recommended for better Windows UX
- Overall architecture is Windows-compatible
+267
View File
@@ -0,0 +1,267 @@
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
View File
@@ -0,0 +1,421 @@
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
}
}
+244
View File
@@ -0,0 +1,244 @@
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
}
+60
View File
@@ -0,0 +1,60 @@
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}
}
}
+179
View File
@@ -0,0 +1,179 @@
const std = @import("std");
// Although this function looks imperative, it does not perform the build
// directly and instead it mutates the build graph (`b`) that will be then
// executed by an external runner. The functions in `std.Build` implement a DSL
// for defining build steps and express dependencies between them, allowing the
// build runner to parallelize the build automatically (and the cache system to
// know when a step doesn't need to be re-run).
pub fn build(b: *std.Build) void {
// Standard target options allow the person running `zig build` to choose
// what target to build for. Here we do not override the defaults, which
// means any target is allowed, and the default is native. Other options
// for restricting supported target set are available.
const target = b.standardTargetOptions(.{});
// Standard optimization options allow the person running `zig build` to select
// between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not
// set a preferred release mode, allowing the user to decide how to optimize.
const optimize = b.standardOptimizeOption(.{});
// It's also possible to define more custom flags to toggle optional features
// of this build script using `b.option()`. All defined flags (including
// target and optimize options) will be listed when running `zig build --help`
// in this directory.
const comma = b.addModule("comma", .{
.root_source_file = b.path("src/comma.zig"),
.target = target,
});
const sqlite = b.dependency("sqlite", .{
.target = target,
.optimize = optimize,
});
// This creates a module, which represents a collection of source files alongside
// some compilation options, such as optimization mode and linked system libraries.
// Zig modules are the preferred way of making Zig code available to consumers.
// addModule defines a module that we intend to make available for importing
// to our consumers. We must give it a name because a Zig package can expose
// multiple modules and consumers will need to be able to specify which
// module they want to access.
const mod = b.addModule("envr", .{
// The root source file is the "entry point" of this module. Users of
// this module will only be able to access public declarations contained
// in this file, which means that if you have declarations that you
// intend to expose to consumers that were defined in other files part
// of this module, you will have to make sure to re-export them from
// the root file.
.root_source_file = b.path("src/root.zig"),
// Later on we'll use this module as the root module of a test executable
// which requires us to specify a target.
.target = target,
.imports = &.{
.{ .name = "comma", .module = comma },
},
});
mod.addImport("sqlite", sqlite.module("sqlite"));
// Here we define an executable. An executable needs to have a root module
// which needs to expose a `main` function. While we could add a main function
// to the module defined above, it's sometimes preferable to split business
// logic and the CLI into two separate modules.
//
// If your goal is to create a Zig library for others to use, consider if
// it might benefit from also exposing a CLI tool. A parser library for a
// data serialization format could also bundle a CLI syntax checker, for example.
//
// If instead your goal is to create an executable, consider if users might
// be interested in also being able to embed the core functionality of your
// program in their own executable in order to avoid the overhead involved in
// subprocessing your CLI tool.
//
// If neither case applies to you, feel free to delete the declaration you
// don't need and to put everything under a single module.
const exe = b.addExecutable(.{
.name = "envr",
.root_module = b.createModule(.{
// b.createModule defines a new module just like b.addModule but,
// unlike b.addModule, it does not expose the module to consumers of
// this package, which is why in this case we don't have to give it a name.
.root_source_file = b.path("src/main.zig"),
// Target and optimization levels must be explicitly wired in when
// defining an executable or library (in the root module), and you
// can also hardcode a specific target for an executable or library
// definition if desireable (e.g. firmware for embedded devices).
.target = target,
.optimize = optimize,
// List of modules available for import in source files part of the
// root module.
.imports = &.{
// Here "envr" is the name you will use in your source code to
// import this module (e.g. `@import("envr")`). The name is
// repeated because you are allowed to rename your imports, which
// can be extremely useful in case of collisions (which can happen
// importing modules from different packages).
.{ .name = "comma", .module = comma },
.{ .name = "envr", .module = mod },
},
}),
});
const version = b.option([]const u8, "version", "application version string") orelse "dev";
const options = b.addOptions();
options.addOption([]const u8, "version", version);
exe.root_module.addOptions("config", options);
// This declares intent for the executable to be installed into the
// install prefix when running `zig build` (i.e. when executing the default
// step). By default the install prefix is `zig-out/` but can be overridden
// by passing `--prefix` or `-p`.
b.installArtifact(exe);
// This creates a top level step. Top level steps have a name and can be
// invoked by name when running `zig build` (e.g. `zig build run`).
// This will evaluate the `run` step rather than the default step.
// For a top level step to actually do something, it must depend on other
// steps (e.g. a Run step, as we will see in a moment).
const run_step = b.step("run", "Run the app");
// This creates a RunArtifact step in the build graph. A RunArtifact step
// invokes an executable compiled by Zig. Steps will only be executed by the
// runner if invoked directly by the user (in the case of top level steps)
// or if another step depends on it, so it's up to you to define when and
// how this Run step will be executed. In our case we want to run it when
// the user runs `zig build run`, so we create a dependency link.
const run_cmd = b.addRunArtifact(exe);
run_step.dependOn(&run_cmd.step);
// By making the run step depend on the default step, it will be run from the
// installation directory rather than directly from within the cache directory.
run_cmd.step.dependOn(b.getInstallStep());
// This allows the user to pass arguments to the application in the build
// command itself, like this: `zig build run -- arg1 arg2 etc`
if (b.args) |args| {
run_cmd.addArgs(args);
}
// Creates an executable that will run `test` blocks from the provided module.
// Here `mod` needs to define a target, which is why earlier we made sure to
// set the releative field.
const mod_tests = b.addTest(.{
.root_module = mod,
});
// A run step that will run the test executable.
const run_mod_tests = b.addRunArtifact(mod_tests);
// Creates an executable that will run `test` blocks from the executable's
// root module. Note that test executables only test one module at a time,
// hence why we have to create two separate ones.
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
});
// A run step that will run the second test executable.
const run_exe_tests = b.addRunArtifact(exe_tests);
// A top level step for running all tests. dependOn can be called multiple
// times and since the two run steps do not depend on one another, this will
// make the two of them run in parallel.
const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_mod_tests.step);
test_step.dependOn(&run_exe_tests.step);
// Just like flags, top level steps are also listed in the `--help` menu.
//
// The Zig build system is entirely implemented in userland, which means
// that it cannot hook into private compiler APIs. All compilation work
// orchestrated by the build system will result in other Zig compiler
// subcommands being invoked with the right flags defined. You can observe
// these invocations when one fails (or you pass a flag to increase
// verbosity) to validate assumptions and diagnose problems.
//
// Lastly, the Zig build system is relatively simple and self-contained,
// and reading its source code will allow you to master it.
}
+84
View File
@@ -0,0 +1,84 @@
.{
// This is the default name used by packages depending on this one. For
// example, when a user runs `zig fetch --save <url>`, this field is used
// as the key in the `dependencies` table. Although the user can choose a
// different name, most users will stick with this provided value.
//
// It is redundant to include "zig" in this name because it is already
// within the Zig package namespace.
.name = .envr,
// This is a [Semantic Version](https://semver.org/).
// In a future version of Zig it will be used for package deduplication.
.version = "0.3.0",
// Together with name, this represents a globally unique package
// identifier. This field is generated by the Zig toolchain when the
// package is first created, and then *never changes*. This allows
// unambiguous detection of one package being an updated version of
// another.
//
// When forking a Zig project, this id should be regenerated (delete the
// field and run `zig build`) if the upstream project is still maintained.
// Otherwise, the fork is *hostile*, attempting to take control over the
// original project's identity. Thus it is recommended to leave the comment
// on the following line intact, so that it shows up in code reviews that
// modify the field.
.fingerprint = 0xa89bf067266a3e10, // Changing this has security and trust implications.
// Tracks the earliest Zig version that the package considers to be a
// supported use case.
.minimum_zig_version = "0.16.0",
// This field is optional.
// Each dependency must either provide a `url` and `hash`, or a `path`.
// `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
// Once all dependencies are fetched, `zig build` no longer requires
// internet connectivity.
.dependencies = .{
// .age = .{ .path = "zig-vendor/age-ffi/zig" },
.sqlite = .{ .path = "zig-vendor/zig-sqlite" },
// See `zig fetch --save <url>` for a command-line interface for adding dependencies.
//.example = .{
// // When updating this field to a new URL, be sure to delete the corresponding
// // `hash`, otherwise you are communicating that you expect to find the old hash at
// // the new URL. If the contents of a URL change this will result in a hash mismatch
// // which will prevent zig from using it.
// .url = "https://example.com/foo.tar.gz",
//
// // This is computed from the file contents of the directory of files that is
// // obtained after fetching `url` and applying the inclusion rules given by
// // `paths`.
// //
// // This field is the source of truth; packages do not come from a `url`; they
// // come from a `hash`. `url` is just one of many possible mirrors for how to
// // obtain a package matching this `hash`.
// //
// // Uses the [multihash](https://multiformats.io/multihash/) format.
// .hash = "...",
//
// // When this is provided, the package is found in a directory relative to the
// // build root. In this case the package's hash is irrelevant and therefore not
// // computed. This field and `url` are mutually exclusive.
// .path = "foo",
//
// // When this is set to `true`, a package is declared to be lazily
// // fetched. This makes the dependency only get fetched if it is
// // actually used.
// .lazy = false,
//},
},
// Specifies the set of files and directories that are included in this package.
// Only files and directories listed here are included in the `hash` that
// is computed for this package. Only files listed here will remain on disk
// when using the zig package manager. As a rule of thumb, one should list
// files required for compilation plus any license(s).
// Paths are relative to the build root. Use the empty string (`""`) to refer to
// the build root itself.
// A directory listed here means that all files within, recursively, are included.
.paths = .{
"build.zig",
"build.zig.zon",
"src",
// For example...
//"LICENSE",
//"README.md",
},
}
+146 -388
View File
@@ -1,58 +1,17 @@
package main package main
import "base:runtime"
import "core:bufio" import "core:bufio"
import "core:fmt" import "core:fmt"
import "core:io" import "core:io"
import "core:mem"
import "core:os" import "core:os"
import "core:reflect"
import "core:strings" import "core:strings"
import "core:terminal"
import "core:text/table"
Command :: struct { Command :: struct {
name: string, name: string,
args: [dynamic]string, args: [dynamic]string,
flags: Flags, flags: map[string]string,
out_buf: ^bufio.Writer, bool_set: map[string]bool,
out: io.Writer,
err: io.Writer,
}
Flags :: struct {
help: bool `args:"short=h" usage:"show this documentation"`,
config_file: string `args:"name=config-file,short=c" usage:"config file" default:"~/.envr/config.json"`,
output: Output_Format `args:"short=o" usage:"the format of output data" default:"table" completion:"output"`,
color: Color_Mode `usage:"Whether or not to colorize output" default:"auto" completion:"color"`,
force: bool `args:"short=f" usage:"Overwrite existing config"`,
}
Flag_Type :: enum {
Help,
Config_File,
Output,
Color,
Force,
}
Output_Format :: enum {
Auto,
Table,
JSON,
}
Color_Mode :: enum {
Auto,
Always,
Never,
}
Positional_Arg :: struct {
name: string,
ntype: string, // nushell type: "path", "string". "" defaults to "path"
completion: string,
optional: bool,
desc: string,
} }
CommandInfo :: struct { CommandInfo :: struct {
@@ -61,336 +20,95 @@ CommandInfo :: struct {
short: string, short: string,
long: string, long: string,
aliases: []string, aliases: []string,
// Flags supported by the command
flags: bit_set[Flag_Type],
args: []Positional_Arg,
} }
GLOBAL_FLAGS: bit_set[Flag_Type] = {.Help, .Config_File, .Color}
COMMANDS := []CommandInfo { COMMANDS := []CommandInfo {
{ {
name = "init", "init",
usage = "envr init", "envr init",
short = "Set up envr", "Set up envr",
long = `The init command generates your initial config and saves it to "The init command generates your initial config and saves it to\n~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to\nencrypt your databse. **Make 100% sure** that you have **a remote copy** of this\nkey somewhere, otherwise your data could be lost forever.",
~/.envr/config in JSON format.\n\nDuring 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.`,
flags = GLOBAL_FLAGS + {.Force},
}, },
{"scan", "envr scan", "Find and select .env files for backup", "", {}},
{"sync", "envr sync", "Update or restore your env backups", "", {}},
{"backup", "envr backup <path>", "Import a .env file into envr", "", {"add"}},
{"restore", "envr restore <path>", "Restore a .env file from the database", "", {}},
{"list", "envr list", "View your tracked files", "", {}},
{"remove", "envr remove <path>", "Remove a .env file from your database", "", {}},
{"check", "envr check [path]", "Check if files are backed up", "", {}},
{ {
name = "scan", "deps",
usage = "envr scan", "envr deps",
short = "Find and select .env files for backup", "Check for missing binaries",
flags = GLOBAL_FLAGS, "envr relies on external binaries for certain functionality.\n\nThe check command reports on which binaries are available and which are not.",
}, {},
{
name = "sync",
usage = "envr sync",
short = "Update or restore your env backups",
flags = GLOBAL_FLAGS + {.Output},
},
{
name = "backup",
usage = "envr backup <path>",
short = "Import a .env file into envr",
aliases = {"add"},
flags = GLOBAL_FLAGS,
args = {
{name = "path", completion = "untracked-paths", desc = "Path to .env file to backup"},
},
},
{
name = "restore",
usage = "envr restore <path>",
short = "Restore a .env file from the database",
flags = GLOBAL_FLAGS,
args = {
{name = "path", completion = "tracked-paths", desc = "Path to .env file to restore"},
},
},
{
name = "list",
usage = "envr list",
short = "View your tracked files",
flags = GLOBAL_FLAGS + {.Output},
},
{
name = "remove",
usage = "envr remove <path>",
short = "Remove a .env file from your database",
flags = GLOBAL_FLAGS,
args = {
{name = "path", completion = "tracked-paths", desc = "Path to .env file to remove"},
},
},
{
name = "check",
usage = "envr check [path]",
short = "Check if files are backed up",
flags = GLOBAL_FLAGS,
args = {
{
name = "path",
optional = true,
desc = "Path to check (defaults to current directory)",
},
},
},
{name = "version", usage = "envr version", short = "Show envr's version", flags = {.Help}},
{
name = "edit-config",
usage = "envr edit-config",
short = "Edit your config with your default editor",
flags = GLOBAL_FLAGS,
},
{
name = "completion",
usage = "envr completion <shell>",
short = "Generate shell completion scripts",
long = `Supported shells:
nushell
bash`,
flags = {.Help},
args = {
{
name = "shell",
ntype = "string",
completion = "shells",
desc = "Shell to generate completions for",
},
},
},
{
name = "uninstall",
usage = "envr uninstall",
short = "Remove envr configuration and database",
long = "This will remove all files generated by envr from your system.",
flags = GLOBAL_FLAGS + {.Force},
}, },
{"version", "envr version", "Show envr's version", "", {}},
{"edit-config", "envr edit-config", "Edit your config with your default editor", "", {}},
{"nushell-completion", "envr nushell-completion", "Generate custom completions for nushell", "", {}},
} }
// Caller is responsible for calling delete_command(cmd). parse_args :: proc() -> (cmd: Command, ok: bool) {
// FIXME: Works in kinda a wonky and awkward way. args := os.args
parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Command, ok: bool) { if len(args) < 2 {
{ print_usage()
cmd.out_buf = new(bufio.Writer) return Command{}, false
bufio.writer_init(cmd.out_buf, out, allocator = context.allocator)
cmd.out = bufio.writer_to_writer(cmd.out_buf)
cmd.err = err
}
if len(args) < 2 || args[1] == "--help" || args[1] == "-h" {
write_usage(cmd.out)
return cmd, false
} }
cmd.name = args[1] cmd.name = args[1]
cmd.args = make([dynamic]string, 0, len(args[2:]))
overflow := parse_flags(&cmd.flags, args[2:]) if cmd.name == "--help" || cmd.name == "-h" {
for arg in overflow { print_usage()
append(&cmd.args, arg) return Command{}, false
} }
if cmd.flags.output == .Auto { cmd.args = make([dynamic]string)
cmd.flags.output = terminal.is_terminal(os.stdout) ? .Table : .JSON cmd.flags = make(map[string]string)
cmd.bool_set = make(map[string]bool)
i := 2
for i < len(args) {
arg := args[i]
if strings.starts_with(arg, "--") {
key := arg[2:]
if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key] = args[i + 1]
i += 2
} else {
cmd.bool_set[key] = true
i += 1
}
} else if strings.starts_with(arg, "-") && len(arg) == 2 {
key_slice := arg[1:2]
if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key_slice] = args[i + 1]
i += 2
} else {
cmd.bool_set[key_slice] = true
i += 1
}
} else {
append(&cmd.args, arg)
i += 1
}
} }
if cmd.flags.color == .Auto { if has_flag(&cmd, "help") {
cmd.flags.color = terminal.is_terminal(os.stdout) ? .Always : .Never print_command_help(cmd.name)
} return Command{}, false
if cmd.flags.color == .Never {
disable_color = true
}
if cmd.flags.config_file == "" {
// FIXME: Handle err
// TODO: Is this right?
home, _ := os.user_home_dir(context.temp_allocator)
// TODO: should we copy out of the temp_allocator?
cmd.flags.config_file = default_config_path(home, context.temp_allocator)
}
if cmd.flags.help {
print_command_help(&cmd)
return cmd, false
} }
return cmd, true return cmd, true
} }
print_command_help :: proc(cmd: ^Command) { has_flag :: proc(cmd: ^Command, name: string) -> bool {
ok := write_command_help(cmd.name, cmd.out) _, ok := cmd.flags[name]
if !ok { if ok {
fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name) return true
write_usage(cmd.out)
}
}
write_command_help :: proc(name: string, w: io.Writer) -> bool {
// TODO: rename info to cmd?
info, found := find_command(name)
if !found {
return false
}
fmt.wprintf(
w,
"%s\n\n\n%s\n\n %s [flags]\n\n",
info.short,
colorize(.Heading, "Usage:"),
colorize(.Flag, info.usage),
flush = false,
)
if len(info.aliases) > 0 {
fmt.wprintf(
w,
"\n%s\n\n %s",
colorize(.Heading, "Aliases:"),
colorize(.Command, info.name),
flush = false,
)
for a in info.aliases {
fmt.wprintf(w, ", %s", colorize(.Command, a), flush = false)
}
fmt.wprintf(w, "\n", flush = false)
}
if len(info.long) > 0 {
fmt.wprintf(w, "\n%s\n", info.long, flush = false)
}
tbl: table.Table
table.init(&tbl, context.temp_allocator, context.temp_allocator)
table.padding(&tbl, 2, 0)
write_flags_table(&tbl, info.flags)
fmt.wprintf(w, "\n", flush = false)
write_borderless_table(w, &tbl)
table_reset(&tbl)
return true
}
Flag_Kind :: enum {
Bool,
String,
Enum,
}
Flag_Field :: struct {
long_name: string,
short_name: string,
kind: Flag_Kind,
usage: string,
default_val: string,
enum_values: string,
completion: string,
}
write_flags_table :: proc(tbl: ^table.Table, flags: bit_set[Flag_Type]) {
table.caption(tbl, "Flags:")
for ft in Flag_Type {
if ft not_in flags do continue
names, hint, desc := flag_field_info(ft)
if len(hint) > 0 {
display := table.format(
tbl,
"%s%s",
colorize(.Flag, names, tbl.format_allocator),
hint,
)
table.row(tbl, display, desc)
} else {
table.row(tbl, colorize(.Flag, names, tbl.format_allocator), desc)
}
}
}
flag_field_info :: proc(
ft: Flag_Type,
) -> (
names: string,
value_hint: string,
description: string,
) {
f := flag_field(ft)
if len(f.short_name) > 0 {
names = fmt.tprintf("-%s, --%s", f.short_name, f.long_name)
} else {
names = fmt.tprintf("--%s", f.long_name)
}
switch f.kind {
case .Bool:
value_hint = ""
case .String:
value_hint = " <value>"
case .Enum:
value_hint = fmt.tprintf(" %s", f.enum_values)
}
description = f.usage
if len(f.default_val) > 0 {
switch f.kind {
case .Bool:
// do nothing
case .String:
description = fmt.tprintf(`%s (default "%s")`, f.usage, f.default_val)
case .Enum:
description = fmt.tprintf("%s (default '%s')", f.usage, f.default_val)
}
}
return
}
flag_field :: proc(ft: Flag_Type) -> Flag_Field {
field := reflect.struct_field_at(Flags, int(ft))
args_tag := reflect.struct_tag_get(field.tag, "args")
long_name, _ := strings.replace(field.name, "_", "-", -1, context.temp_allocator)
if n, ok := get_subtag(args_tag, "name"); ok {
long_name = n
}
short, has_short := get_subtag(args_tag, "short")
base_ti := runtime.type_info_base(field.type)
kind: Flag_Kind
enum_values: string
if _, is_bool := base_ti.variant.(runtime.Type_Info_Boolean); is_bool {
kind = .Bool
} else if _, is_string := base_ti.variant.(runtime.Type_Info_String); is_string {
kind = .String
} else if enum_ti, is_enum := base_ti.variant.(runtime.Type_Info_Enum); is_enum {
kind = .Enum
parts := make([dynamic]string, 0, len(enum_ti.names), context.temp_allocator)
for name in enum_ti.names {
lower := strings.to_lower(name, context.temp_allocator)
append(&parts, fmt.tprintf("'%s'", lower))
}
enum_values = strings.join(parts[:], "|", context.temp_allocator)
delete(parts)
}
usage := reflect.struct_tag_get(field.tag, "usage")
default_val := reflect.struct_tag_get(field.tag, "default")
completion := reflect.struct_tag_get(field.tag, "completion")
return {
long_name = long_name,
short_name = has_short ? short : "",
kind = kind,
usage = usage,
default_val = default_val,
enum_values = enum_values,
completion = completion,
} }
_, ok2 := cmd.bool_set[name]
return ok2
} }
find_command :: proc(name: string) -> (CommandInfo, bool) { find_command :: proc(name: string) -> (CommandInfo, bool) {
@@ -407,11 +125,53 @@ find_command :: proc(name: string) -> (CommandInfo, bool) {
return CommandInfo{}, false return CommandInfo{}, false
} }
ENVR_DESCRIPTION :: `envr keeps your .env synced to a local, encrypted database. write_command_help :: proc(name: string, w: io.Writer) -> bool {
info, found := find_command(name)
if !found {
return false
}
fmt.wprintf(w, "Usage: %s [flags]\n\n", info.usage, flush = false)
fmt.wprintf(w, "%s\n", info.short, flush = false)
if len(info.aliases) > 0 {
fmt.wprintf(w, "\nAliases:\n %s", info.name, flush = false)
for a in info.aliases {
fmt.wprintf(w, ", %s", a, flush = false)
}
fmt.wprintf(w, "\n", flush = false)
}
if len(info.long) > 0 {
fmt.wprintf(w, "\n%s\n", info.long, flush = false)
}
fmt.wprintf(w, "\nFlags:\n -h, --help help for %s\n", info.name, flush = false)
return true
}
print_command_help :: proc(name: string) {
bw: bufio.Writer
bufio.writer_init(&bw, io.to_writer(os.to_writer(os.stdout)), mem.DEFAULT_PAGE_SIZE)
defer bufio.writer_destroy(&bw)
w := bufio.writer_to_writer(&bw)
ok := write_command_help(name, w)
if !ok {
fmt.printf("Unknown command: %s\n", name)
print_usage()
}
bufio.writer_flush(&bw)
}
write_usage :: proc(w: io.Writer) {
fmt.wprintf(
w,
`envr keeps your .env synced to a local, age encrypted database.
Is a safe and easy way to gather all your .env files in one place where they can Is a safe and easy way to gather all your .env files in one place where they can
easily be backed by another tool such as restic or git. easily be backed by another tool such as restic or git.
All your data is stored in ~/.envr/data.envr All your data is stored in ~/data.age
Getting started is easy: Getting started is easy:
@@ -436,53 +196,51 @@ Select the files you want to back up from the interactive list.
5. If you lose a repository, after re-cloning the repo into the same path it was 5. If you lose a repository, after re-cloning the repo into the same path it was
at before, restore your backup with: at before, restore your backup with:
> envr restore ~/<path to repository>/.env` > envr restore ~/<path to repository>/.env
// TODO: command args should be shown in usage. Usage:
write_usage :: proc(w: io.Writer) { envr [command]
fmt.wprintf(
w, Available Commands:
"%s\n\n%s\n\n %s [command]\n\n", `,
ENVR_DESCRIPTION,
colorize(.Heading, "Usage:"),
colorize(.Flag, "envr"),
flush = false, flush = false,
) )
tbl: table.Table
table.init(&tbl, context.temp_allocator, context.temp_allocator)
table.padding(&tbl, 2, 0)
table.caption(&tbl, "Available Commands:")
for c in COMMANDS { for c in COMMANDS {
name := c.name name_start := len(c.name)
// TODO: Can we do better? fmt.wprintf(w, "%s", c.name, flush = false)
for a in c.aliases { for a in c.aliases {
name = strings.join([]string{name, a}, ", ", tbl.format_allocator) fmt.wprintf(w, ", %s", a, flush = false)
name_start += len(a) + 2
} }
table.row(&tbl, colorize(.Command, name, tbl.format_allocator), c.short) padding := 20 - name_start
if padding > 0 {
for _ in 0 ..< padding {
io.write_byte(w, ' ')
}
}
fmt.wprintf(w, " %s\n", c.short, flush = false)
} }
write_borderless_table(w, &tbl)
table_reset(&tbl)
write_flags_table(&tbl, GLOBAL_FLAGS)
write_borderless_table(w, &tbl)
table_reset(&tbl)
fmt.wprintf( fmt.wprintf(
w, w,
`Use "%s [command] --help" for more information about a command.`, `
colorize(.Flag, "envr", tbl.format_allocator), Flags:
-h, --help help for envr
Use "envr [command] --help" for more information about a command.
`,
flush = false, flush = false,
) )
} }
delete_command :: proc(cmd: ^Command) { // TODO: Look at usages,might want to pass a writer
bufio.writer_flush(cmd.out_buf) print_usage :: proc() {
delete(cmd.args) bw: bufio.Writer
bufio.writer_destroy(cmd.out_buf) bufio.writer_init(&bw, io.to_writer(os.to_writer(os.stdout)), mem.DEFAULT_PAGE_SIZE)
free(cmd.out_buf) defer bufio.writer_destroy(&bw)
defer bufio.writer_flush(&bw)
write_usage(bufio.writer_to_writer(&bw))
} }
+35 -188
View File
@@ -1,9 +1,7 @@
#+feature dynamic-literals #+feature dynamic-literals
package main package main
package main
import "core:fmt" import "core:fmt"
import "core:fmt"
import "core:strings" import "core:strings"
import "core:testing" import "core:testing"
@@ -58,7 +56,7 @@ test_usage_text_contains_flags_and_help_hint :: proc(t: ^testing.T) {
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section") testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
testing.expect(t, strings.contains(text, "--help"), "missing --help flag") testing.expect(t, strings.contains(text, "--help"), "missing --help flag")
testing.expect(t, strings.contains(text, "Use \"envr [command] --help\""), "missing help hint") testing.expect(t, strings.contains(text, "Use \"envr [command] --help\""), "missing help hint")
} }
@(test) @(test)
test_command_help_backup :: proc(t: ^testing.T) { test_command_help_backup :: proc(t: ^testing.T) {
@@ -110,8 +108,7 @@ test_command_help_init_no_aliases :: proc(t: ^testing.T) {
testing.expect(t, !strings.contains(text, "Aliases:"), "init should not have Aliases section") testing.expect(t, !strings.contains(text, "Aliases:"), "init should not have Aliases section")
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section") testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
testing.expect(t, strings.contains(text, "help for init"), "missing 'help for init'") testing.expect(t, strings.contains(text, "help for init"), "missing 'help for init'")
testing.expect(t, strings.contains(text, "--force"), "missing --force flag") }
}
@(test) @(test)
test_command_help_unknown :: proc(t: ^testing.T) { test_command_help_unknown :: proc(t: ^testing.T) {
@@ -124,7 +121,7 @@ test_command_help_unknown :: proc(t: ^testing.T) {
text := strings.to_string(b) text := strings.to_string(b)
testing.expect(t, len(text) == 0, "text should be empty for unknown command") testing.expect(t, len(text) == 0, "text should be empty for unknown command")
} }
@(test) @(test)
test_command_help_version :: proc(t: ^testing.T) { test_command_help_version :: proc(t: ^testing.T) {
@@ -145,200 +142,50 @@ test_command_help_version :: proc(t: ^testing.T) {
} }
@(test) @(test)
args: []string, test_has_flag_bool_set :: proc(t: ^testing.T) {
) -> ( cmd := Command {
cmd: Command, name = "test",
ok: bool, bool_set = map[string]bool{"force" = true},
out_text: string, }
err_text: string,
) {
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok = parse_args(args, strings.to_stream(&out_b), strings.to_stream(&err_b))
if ok {
bufio.writer_flush(cmd.out_buf)
out_text = strings.to_string(out_b)
err_text = strings.to_string(err_b)
}
defer delete(cmd.bool_set) defer delete(cmd.bool_set)
testing.expect(t, has_flag(&cmd, "force"), "should find flag in bool_set") testing.expect(t, has_flag(&cmd, "force"), "should find flag in bool_set")
} testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag")
}
@(test) @(test)
test_has_flag_value_map :: proc(t: ^testing.T) { test_has_flag_value_map :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list"}) cmd := Command {
testing.expect(t, ok, "should succeed") name = "test",
if !ok do return flags = map[string]string{"output" = "/tmp/out"},
defer delete_command(&cmd) }
defer delete(cmd.flags)
testing.expect(t, has_flag(&cmd, "output"), "should find flag in flags map") testing.expect(t, has_flag(&cmd, "output"), "should find flag in flags map")
testing.expect_value(t, len(cmd.args), 0) testing.expect(t, !has_flag(&cmd, "force"), "should not find missing flag")
} }
@(test) @(test)
test_has_flag_both_maps :: proc(t: ^testing.T) { test_has_flag_both_maps :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "/project/.env"}) cmd := Command {
defer delete_command(&cmd) name = "test",
testing.expect(t, ok, "should succeed") flags = map[string]string{"output" = "/tmp/out"},
bool_set = map[string]bool{"force" = true},
}
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, has_flag(&cmd, "output"), "should find in flags") testing.expect(t, has_flag(&cmd, "output"), "should find in flags")
testing.expect_value(t, len(cmd.args), 1) testing.expect(t, has_flag(&cmd, "force"), "should find in bool_set")
testing.expect_value(t, cmd.args[0], "/project/.env") testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag")
} }
@(test) @(test)
test_has_flag_empty_command :: proc(t: ^testing.T) { test_has_flag_empty_command :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args( cmd := Command {
[]string{"envr", "sync", "--config-file", "x.json"}, name = "test",
) }
testing.expect(t, ok, "should succeed") testing.expect(t, !has_flag(&cmd, "anything"), "empty command should have no flags")
if !ok do return }
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.config_file, "x.json")
}
test_parse_args_config_file_short_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "-c", "x.json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.config_file, "x.json")
}
@(test)
test_parse_args_force_long_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "--force"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.force, true)
}
@(test)
test_parse_args_force_short_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "-f"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.force, true)
}
@(test)
test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "a", "b"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, len(cmd.args), 2)
testing.expect_value(t, cmd.args[0], "a")
testing.expect_value(t, cmd.args[1], "b")
}
@(test)
test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "/project/.env", "--force"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.force, true)
testing.expect_value(t, len(cmd.args), 1)
testing.expect_value(t, cmd.args[0], "/project/.env")
}
@(test)
test_parse_args_no_args :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr"})
defer delete_command(&cmd)
testing.expect(t, !ok, "no args should return false")
}
@(test)
test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "--force", "a.env", "--output", "json"})
defer delete_command(&cmd)
testing.expect(t, ok, "should succeed")
testing.expect_value(t, cmd.flags.force, true)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
testing.expect_value(t, len(cmd.args), 1)
testing.expect_value(t, cmd.args[0], "a.env")
}
@(test)
test_parse_args_config_file_default :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, len(cmd.flags.config_file) > 0, "config_file should default to non-empty path")
testing.expect(
t,
strings.contains(cmd.flags.config_file, ".envr"),
"default config_file should contain .envr dir, got %s",
)
}
@(test)
test_parse_args_output_long_json :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--output", "json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
}
@(test)
test_parse_args_output_short_json :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-o", "json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
}
@(test)
test_parse_args_output_long_table :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--output", "table"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.Table)
}
@(test)
test_parse_args_output_short_table :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-o", "table"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.Table)
}
@(test)
test_parse_args_output_equals_syntax :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--output=json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
}
}
+56
View File
@@ -0,0 +1,56 @@
/*
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")
}
+109
View File
@@ -0,0 +1,109 @@
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)
}
+51
View File
@@ -0,0 +1,51 @@
package cmd
import (
"os"
"github.com/olekukonko/tablewriter"
"github.com/sbrow/envr/app"
"github.com/spf13/cobra"
)
var depsCmd = &cobra.Command{
Use: "deps",
Short: "Check for missing binaries",
Long: `envr relies on external binaries for certain functionality.
The check command reports on which binaries are available and which are not.`,
RunE: func(cmd *cobra.Command, args []string) error {
db, err := app.Open()
if err != nil {
return err
} else {
defer db.Close()
features := db.Features()
table := tablewriter.NewWriter(os.Stdout)
table.Header([]string{"Feature", "Status"})
// Check Git
if features&app.Git == 1 {
table.Append([]string{"Git", "✓ Available"})
} else {
table.Append([]string{"Git", "✗ Missing"})
}
// Check fd
if features&app.Fd == app.Fd {
table.Append([]string{"fd", "✓ Available"})
} else {
table.Append([]string{"fd", "✗ Missing"})
}
table.Render()
return nil
}
},
}
func init() {
rootCmd.AddCommand(depsCmd)
}
+55
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)
}
+96
View File
@@ -0,0 +1,96 @@
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
}
+69
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()
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
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
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
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()
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
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()
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
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
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(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
}
+101
View File
@@ -0,0 +1,101 @@
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)
}
+36
View File
@@ -0,0 +1,36 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var (
version = "dev"
commit = "none"
date = "unknown"
)
var long bool
// versionCmd represents the version command
// Deprecated: Remove when Zig has the chance to emit help
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)
}
+4 -8
View File
@@ -5,25 +5,22 @@ import "core:strings"
cmd_backup :: proc(cmd: ^Command) { cmd_backup :: proc(cmd: ^Command) {
if len(cmd.args) != 1 { if len(cmd.args) != 1 {
print_command_help(cmd) print_command_help("backup")
return return
} }
path := cmd.args[0] path := cmd.args[0]
if len(strings.trim_space(path)) == 0 { if len(strings.trim_space(path)) == 0 {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false) fmt.println("Error: No path provided")
return return
} }
// TODO: allow new_env_file to accept allocator?
// TODO: Write a test that covers this leak
file, ok := new_env_file(path) file, ok := new_env_file(path)
defer delete_envfile(&file)
if !ok { if !ok {
return return
} }
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open()
if !db_ok { if !db_ok {
return return
} }
@@ -33,6 +30,5 @@ cmd_backup :: proc(cmd: ^Command) {
return return
} }
fmt.wprintf(cmd.out, "Saved %s into the database\n", path, flush = false) fmt.printf("Saved %s into the database\n", path)
} }
+38 -33
View File
@@ -3,47 +3,61 @@ package main
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings"
// TODO: What happens if you pass a non existent path to cmd_check?
// TODO: UX could be improved, so "run envr add ." if file not exists.
cmd_check :: proc(cmd: ^Command) { cmd_check :: proc(cmd: ^Command) {
_check_path: string feats := check_features()
check_path: string
if len(cmd.args) > 0 { if len(cmd.args) > 0 {
_check_path = cmd.args[0] check_path = cmd.args[0]
} else { } else {
cwd, cwd_err := os.get_working_directory(context.temp_allocator) cwd, cwd_err := os.get_working_directory(context.allocator)
if cwd_err != nil { if cwd_err != nil {
fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false) fmt.printf("Error getting current directory: %v\n", cwd_err)
return return
} }
_check_path = cwd check_path = cwd
}
check_path, abs_err := filepath.abs(_check_path, context.temp_allocator)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return
} }
db, db_ok := db_open(cmd.flags.config_file) abs_path: string
if filepath.is_abs(check_path) {
abs_path = check_path
} else {
resolved, abs_err := filepath.abs(check_path)
if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err)
return
}
abs_path = resolved
}
db, db_ok := db_open()
if !db_ok { if !db_ok {
return return
} }
defer db_close(&db) defer db_close(&db)
is_dir := os.is_directory(check_path) is_dir := os.is_directory(abs_path)
// TODO: set a reasonable default files_in_path: [dynamic]string
files_in_path := make([dynamic]string, context.temp_allocator)
if is_dir { if is_dir {
scanned, scan_ok := scan_path(check_path, db.cfg) if cant_scan(feats) {
fmt.println(
"Error: please install fd to use the check command (https://github.com/sharkdp/fd)",
)
return
}
scanned, scan_ok := scan_path(abs_path, db.cfg)
if !scan_ok { if !scan_ok {
fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false) fmt.println("Error scanning directory for .env files")
return return
} }
files_in_path = scanned files_in_path = scanned
} else { } else {
append(&files_in_path, check_path) append(&files_in_path, abs_path)
} }
db_files, list_ok := db_list(&db) db_files, list_ok := db_list(&db)
@@ -55,25 +69,16 @@ cmd_check :: proc(cmd: ^Command) {
if len(not_backed) == 0 { if len(not_backed) == 0 {
if len(files_in_path) == 0 { if len(files_in_path) == 0 {
fmt.wprintln(cmd.out, "No .env files found in the specified directory.", flush = false) fmt.println("No .env files found in the specified directory.")
} else { } else {
fmt.wprintln( fmt.println("✓ All .env files in the directory are backed up.")
cmd.out,
"✓ All .env files in the directory are backed up.",
flush = false,
)
} }
} else { } else {
fmt.wprintf( fmt.printf("Found %d .env file(s) that are not backed up:\n", len(not_backed))
cmd.out,
"Found %d .env file(s) that are not backed up:\n",
len(not_backed),
flush = false,
)
for file in not_backed { for file in not_backed {
fmt.wprintf(cmd.out, " %s\n", file, flush = false) fmt.printf(" %s\n", file)
} }
fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false) fmt.println("\nRun 'envr sync' to back up these files.")
} }
} }
+13 -9
View File
@@ -1,36 +1,40 @@
#+test
package main package main
import "core:fmt"
import "core:testing" import "core:testing"
@(test) @(test)
test_find_unbacked_finds_missing :: proc(t: ^testing.T) { test_find_unbacked_finds_missing :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env", "/c/.env"} local := []string{"/a/.env", "/b/.env", "/c/.env"}
db := []EnvFile{{path = "/a/.env"}, {path = "/b/.env"}} db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}}
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect_value(t, len(result), 1) testing.expect(t, len(result) == 1, fmt.tprintf("expected 1 unbacked, got %d", len(result)))
if len(result) > 0 { if len(result) > 0 {
testing.expect_value(t, result[0], "/c/.env") testing.expect(
t,
result[0] == "/c/.env",
fmt.tprintf("expected /c/.env, got %s", result[0]),
)
} }
} }
@(test) @(test)
test_find_unbacked_all_backed :: proc(t: ^testing.T) { test_find_unbacked_all_backed :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env"} local := []string{"/a/.env", "/b/.env"}
db := []EnvFile{{path = "/a/.env"}, {path = "/b/.env"}} db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}}
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect_value(t, len(result), 0) testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result)))
} }
@(test) @(test)
test_find_unbacked_no_local :: proc(t: ^testing.T) { test_find_unbacked_no_local :: proc(t: ^testing.T) {
local: []string local: []string
db := []EnvFile{{path = "/a/.env"}} db := []EnvFile{{Path = "/a/.env"}}
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect_value(t, len(result), 0) testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result)))
} }
@(test) @(test)
@@ -39,6 +43,6 @@ test_find_unbacked_none_backed :: proc(t: ^testing.T) {
db: []EnvFile db: []EnvFile
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect_value(t, len(result), 2) testing.expect(t, len(result) == 2, fmt.tprintf("expected 2 unbacked, got %d", len(result)))
} }
-268
View File
@@ -1,268 +0,0 @@
package main
import "core:fmt"
import "core:strings"
nushell_header :: `def tracked-paths [] {
(
^envr list
| from json
| each {
[$in.directory $in.path] | path join
}
)
}
def untracked-paths [] {
(
^envr scan
| from json
)
}
def color [] {
['auto' 'always' 'never']
}
def output [] {
['auto' 'table' 'json']
}
def shells [] {
['nushell' 'bash']
}
`
cmd_completion :: proc(cmd: ^Command) {
if len(cmd.args) == 0 {
print_command_help(cmd)
return
}
switch cmd.args[0] {
case "nushell":
fmt.wprint(cmd.out, generate_nushell_completion(), flush = true)
case "bash":
fmt.wprint(cmd.out, generate_bash_completion(), flush = true)
case:
fmt.wprintf(cmd.err, "Unsupported shell: %s\n", cmd.args[0])
fmt.wprintln(cmd.err, "Supported shells: nushell, bash")
}
}
generate_nushell_completion :: proc() -> string {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
fmt.sbprint(&sb, nushell_header)
for c in COMMANDS {
fmt.sbprintf(&sb, "# %s\n", c.short)
fmt.sbprintf(&sb, "export extern \"envr %s\" [\n", c.name)
for ft in Flag_Type {
if ft not_in c.flags do continue
f := flag_field(ft)
fmt.sbprintf(&sb, " %s\n", nushell_flag_line(f))
}
for arg in c.args {
fmt.sbprintf(&sb, " %s\n", nushell_positional_line(arg))
}
fmt.sbprintf(&sb, "]\n")
for a in c.aliases {
fmt.sbprintf(&sb, "\nexport alias \"envr %s\" = envr %s\n", a, c.name)
}
fmt.sbprintf(&sb, "\n")
}
return strings.to_string(sb)
}
nushell_flag_line :: proc(f: Flag_Field) -> string {
line: string
if len(f.short_name) > 0 {
line = fmt.tprintf("--%s(-%s)", f.long_name, f.short_name)
} else {
line = fmt.tprintf("--%s", f.long_name)
}
switch f.kind {
case .Bool:
case .String:
line = fmt.tprintf("%s: path", line)
case .Enum:
if len(f.completion) > 0 {
line = fmt.tprintf("%s: string@%s", line, f.completion)
} else {
line = fmt.tprintf("%s: string", line)
}
}
return fmt.tprintf("%s # %s", line, f.usage)
}
nushell_positional_line :: proc(arg: Positional_Arg) -> string {
name := arg.name
if arg.optional {
name = fmt.tprintf("%s?", name)
}
ntype := len(arg.ntype) > 0 ? arg.ntype : "path"
if len(arg.completion) > 0 {
return fmt.tprintf("%s: %s@%s", name, ntype, arg.completion)
}
return fmt.tprintf("%s: %s", name, ntype)
}
bash_header :: `
_envr() {
local cur prev cmd
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
cmd="${COMP_WORDS[1]}"
`
bash_footer :: `
}
complete -F _envr envr
`
generate_bash_completion :: proc() -> string {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
fmt.sbprint(&sb, bash_header)
// B: Subcommand completion
fmt.sbprintf(&sb, " if [[ $COMP_CWORD -eq 1 ]]; then\n")
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"")
first := true
for c in COMMANDS {
if !first do fmt.sbprintf(&sb, " ")
fmt.sbprintf(&sb, "%s", c.name)
first = false
for a in c.aliases {
fmt.sbprintf(&sb, " %s", a)
}
}
fmt.sbprintf(&sb, "\" -- \"$cur\") )\n")
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " fi\n\n")
// C: Flag value completion
fmt.sbprintf(&sb, " case \"$prev\" in\n")
for ft in Flag_Type {
f := flag_field(ft)
if f.kind == .Bool do continue
pattern := fmt.tprintf("--%s", f.long_name)
if len(f.short_name) > 0 {
pattern = fmt.tprintf("%s|-%s", pattern, f.short_name)
}
fmt.sbprintf(&sb, " %s)\n", pattern)
switch f.kind {
case .Enum:
fmt.sbprintf(
&sb,
" COMPREPLY=( $(compgen -W \"%s\" -- \"$cur\") )\n",
bash_enum_values(f.enum_values),
)
case .String:
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -f -- \"$cur\") )\n")
case .Bool:
panic("unexpected")
}
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " ;;\n")
}
fmt.sbprintf(&sb, " esac\n\n")
// D: Flag name completion per command
fmt.sbprintf(&sb, " case \"$cur\" in -*)\n")
fmt.sbprintf(&sb, " case \"$cmd\" in\n")
for c in COMMANDS {
if c.flags == {} do continue
cmd_pattern := c.name
for a in c.aliases {
cmd_pattern = fmt.tprintf("%s|%s", cmd_pattern, a)
}
fmt.sbprintf(&sb, " %s)\n", cmd_pattern)
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"")
flag_first := true
for ft in Flag_Type {
if ft not_in c.flags do continue
f := flag_field(ft)
if !flag_first do fmt.sbprintf(&sb, " ")
fmt.sbprintf(&sb, "--%s", f.long_name)
if len(f.short_name) > 0 {
fmt.sbprintf(&sb, " -%s", f.short_name)
}
flag_first = false
}
fmt.sbprintf(&sb, "\" -- \"$cur\") )\n")
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " ;;\n")
}
fmt.sbprintf(&sb, " esac\n")
fmt.sbprintf(&sb, " ;;\n")
fmt.sbprintf(&sb, " esac\n\n")
// E: Positional completion
fmt.sbprintf(&sb, " case \"$cmd\" in\n")
for c in COMMANDS {
has_comp := false
for arg in c.args {
if len(arg.completion) > 0 {
has_comp = true
break
}
}
if !has_comp do continue
cmd_pattern := c.name
for a in c.aliases {
cmd_pattern = fmt.tprintf("%s|%s", cmd_pattern, a)
}
fmt.sbprintf(&sb, " %s)\n", cmd_pattern)
for arg in c.args {
if len(arg.completion) == 0 do continue
comp: string
switch arg.completion {
case "tracked-paths":
comp = "$(envr list --output json 2>/dev/null)"
case "untracked-paths":
comp = "$(envr scan --output json 2>/dev/null)"
case "shells":
comp = "nushell bash"
case:
continue
}
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"%s\" -- \"$cur\") )\n", comp)
}
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " ;;\n")
}
fmt.sbprintf(&sb, " esac\n")
fmt.sbprint(&sb, bash_footer)
return strings.to_string(sb)
}
bash_enum_values :: proc(enum_values: string) -> string {
s, _ := strings.replace(enum_values, "'", "", -1, context.temp_allocator)
s, _ = strings.replace(s, "|", " ", -1, context.temp_allocator)
return s
}
-133
View File
@@ -1,133 +0,0 @@
#+test
package main
import "core:fmt"
import "core:strings"
import "core:testing"
@(test)
test_nushell_completion_nonempty :: proc(t: ^testing.T) {
script := generate_nushell_completion()
testing.expect(t, len(script) > 0, "completion script should not be empty")
}
@(test)
test_nushell_completion_contains_commands :: proc(t: ^testing.T) {
script := generate_nushell_completion()
expected := []string{
"tracked-paths",
"untracked-paths",
"envr backup",
"envr check",
"envr edit-config",
"envr init",
"envr list",
"envr remove",
"envr restore",
"envr scan",
"envr sync",
"envr version",
"envr completion",
}
for ext in expected {
testing.expect(
t,
strings.contains(script, ext),
fmt.tprintf("expected script to contain %q", ext),
)
}
}
@(test)
test_nushell_completion_contains_flags :: proc(t: ^testing.T) {
script := generate_nushell_completion()
expected_flags := []string{
"--help(-h)",
"--config-file(-c)",
"--color",
"--force(-f)",
"--output(-o)",
}
for flag in expected_flags {
testing.expect(
t,
strings.contains(script, flag),
fmt.tprintf("expected script to contain %q", flag),
)
}
}
@(test)
test_nushell_completion_contains_aliases :: proc(t: ^testing.T) {
script := generate_nushell_completion()
testing.expect(
t,
strings.contains(script, "envr add"),
"expected script to contain 'envr add' alias",
)
}
@(test)
test_nushell_completion_no_help_command :: proc(t: ^testing.T) {
script := generate_nushell_completion()
testing.expect(
t,
!strings.contains(script, "envr help"),
"script should not contain 'envr help' (not a real command)",
)
}
@(test)
test_bash_completion_nonempty :: proc(t: ^testing.T) {
script := generate_bash_completion()
testing.expect(t, len(script) > 0, "bash completion script should not be empty")
}
@(test)
test_bash_completion_contains_registration :: proc(t: ^testing.T) {
script := generate_bash_completion()
testing.expect(
t,
strings.contains(script, "complete -F _envr envr"),
"expected bash script to register completion function",
)
}
@(test)
test_bash_completion_contains_commands :: proc(t: ^testing.T) {
script := generate_bash_completion()
expected := []string{
"init", "scan", "sync", "backup", "add",
"restore", "list", "remove", "check",
"version", "edit-config", "completion",
}
for cmd in expected {
testing.expect(
t,
strings.contains(script, cmd),
fmt.tprintf("expected bash script to contain %q", cmd),
)
}
}
@(test)
test_bash_completion_contains_flags :: proc(t: ^testing.T) {
script := generate_bash_completion()
expected := []string{
"--help", "--config-file", "--color", "--force", "--output",
}
for flag in expected {
testing.expect(
t,
strings.contains(script, flag),
fmt.tprintf("expected bash script to contain %q", flag),
)
}
}
@(test)
test_bash_completion_contains_enum_values :: proc(t: ^testing.T) {
script := generate_bash_completion()
testing.expect(t, strings.contains(script, "auto table json"), "missing output enum values")
testing.expect(t, strings.contains(script, "auto always never"), "missing color enum values")
}
+30
View File
@@ -0,0 +1,30 @@
package main
import "core:fmt"
cmd_deps :: proc(cmd: ^Command) {
feats := check_features()
headers := []string{"Feature", "Status"}
rows: [dynamic][]string
if .Git in feats {
append(&rows, []string{"Git", "\u2713 Available"})
} else {
append(&rows, []string{"Git", "\u2717 Missing"})
}
if .Fd in feats {
append(&rows, []string{"fd", "\u2713 Available"})
} else {
append(&rows, []string{"fd", "\u2717 Missing"})
}
if .Age in feats {
append(&rows, []string{"age", "\u2713 Available"})
} else {
append(&rows, []string{"age", "\u2717 Missing"})
}
render_table(headers, rows[:])
}
+37 -38
View File
@@ -2,49 +2,48 @@ package main
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath"
import "core:strings"
cmd_edit_config :: proc(cmd: ^Command) { cmd_edit_config :: proc(cmd: ^Command) {
editor := os.get_env("EDITOR", context.allocator) editor := os.get_env("EDITOR", context.allocator)
if len(editor) == 0 { if len(editor) == 0 {
fmt.wprintln(cmd.err, "Error: $EDITOR environment variable is not set", flush = false) fmt.println("Error: $EDITOR environment variable is not set")
return return
} }
config_path := cmd.flags.config_file config_path, join_err := filepath.join([]string{envr_dir(), "config.json"})
if join_err != nil {
fmt.printf("Error building config path: %v\n", join_err)
return
}
if !os.exists(config_path) { _, stat_err := os.stat(config_path, context.allocator)
fmt.wprintf( if stat_err != nil {
cmd.err, fmt.printf("Config file does not exist at %s. Run 'envr init' first.\n", config_path)
"Config file does not exist at %s. Run 'envr init' first.\n", return
config_path, }
flush = false,
)
return
}
args := []string{editor, config_path} args := []string{editor, config_path}
desc := os.Process_Desc { desc := os.Process_Desc{
command = args, command = args,
stdin = os.stdin, stdin = os.stdin,
stdout = os.stdout, stdout = os.stdout,
stderr = os.stderr, stderr = os.stderr,
} }
p, start_err := os.process_start(desc) p, start_err := os.process_start(desc)
if start_err != nil { if start_err != nil {
fmt.wprintf(cmd.err, "Error running editor: %v\n", start_err, flush = false) fmt.printf("Error running editor: %v\n", start_err)
return return
} }
state, wait_err := os.process_wait(p) state, wait_err := os.process_wait(p)
if wait_err != nil { if wait_err != nil {
fmt.wprintf(cmd.err, "Error waiting for editor: %v\n", wait_err, flush = false) fmt.printf("Error waiting for editor: %v\n", wait_err)
return return
} }
if state.exit_code != 0 {
// TODO: Should we call exit inside of commands? os.exit(int(state.exit_code))
if state.exit_code != 0 { }
os.exit(int(state.exit_code))
}
} }
+9 -24
View File
@@ -3,19 +3,12 @@ package main
import "core:fmt" import "core:fmt"
cmd_init :: proc(cmd: ^Command) { cmd_init :: proc(cmd: ^Command) {
force := cmd.flags.force force := has_flag(cmd, "force") || has_flag(cmd, "f")
config_file := cmd.flags.config_file
fmt.wprintln(cmd.out, cmd.flags.config_file, flush = false) _, cfg_exists := load_config()
_, cfg_exists := load_config(config_file)
if cfg_exists && !force { if cfg_exists && !force {
fmt.wprintln( fmt.println("You have already initialized envr.")
cmd.out, fmt.println("Run again with the --force flag if you want to reinitialize.")
`You have already initialized envr.
Run again with the --force flag if you want to reinitialize.`,
flush = false,
)
return return
} }
@@ -25,19 +18,13 @@ Run again with the --force flag if you want to reinitialize.`,
} }
if len(keys) == 0 { if len(keys) == 0 {
fmt.wprintln( fmt.println("No SSH private keys found in ~/.ssh")
cmd.err,
`No ssh-ed25519 keys found in ~/.ssh
Generate one with: ssh-keygen -t ed25519`,
flush = false,
)
return return
} }
selected, result := multi_select("Select SSH private keys:", keys[:]) selected, result := multi_select("Select SSH private keys:", keys[:])
defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.wprintln(cmd.out, colorize(.Message, "Cancelled."), flush = false) fmt.println("\x1b[2mCancelled.\x1b[0m")
return return
} }
@@ -49,20 +36,18 @@ Generate one with: ssh-keygen -t ed25519`,
} }
if len(selected_paths) == 0 { if len(selected_paths) == 0 {
fmt.wprintln(cmd.err, "No SSH keys selected - Config not created", flush = false) fmt.println("No SSH keys selected - Config not created")
return return
} }
cfg := new_config(selected_paths[:], config_file) cfg := new_config(selected_paths[:])
if !save_config(cfg, force = force) { if !save_config(cfg, force = force) {
return return
} }
fmt.wprintf( fmt.printf(
cmd.out,
"Config initialized with %d SSH key(s). You are ready to use envr.\n", "Config initialized with %d SSH key(s). You are ready to use envr.\n",
len(selected_paths), len(selected_paths),
flush = false,
) )
} }
+40 -59
View File
@@ -2,75 +2,56 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
import "core:text/table"
ListEntry :: struct { ListEntry :: struct {
dir: string `json:"directory"`, Directory: string `json:"directory"`,
path: string `json:"path"`, Path: string `json:"path"`,
} }
// TODO: Improve table rendering
cmd_list :: proc(cmd: ^Command) { cmd_list :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open()
if !db_ok { if !db_ok {
return return
} }
defer db_close(&db) defer db_close(&db)
rows, list_ok := db_list(&db) rows, list_ok := db_list(&db)
if !list_ok { if !list_ok {
return return
} }
defer delete(rows)
if cmd.flags.output == .Table { if is_tty() {
t: table.Table headers := []string{"Directory", "Path"}
table.init(&t, context.temp_allocator, context.temp_allocator) table_rows := make([dynamic][]string, 0, len(rows))
table.padding(&t, 1, 1)
table.aligned_header_of_values(
&t,
.Center,
colorize(.Table_Heading, "Directory"),
colorize(.Table_Heading, "Path"),
)
for row in rows { for row in rows {
dir_str := strings.concatenate( dir_str := strings.concatenate({row.Dir, "/"})
{row.dir, os.Path_Separator_String}, filename := filepath.base(row.Path)
context.temp_allocator, row_slice := make([]string, 2)
) row_slice[0] = dir_str
filename := filepath.base(row.path) row_slice[1] = filename
append(&table_rows, row_slice)
}
table.row(&t, dir_str, filename) render_table(headers, table_rows[:])
} } else {
entries: [dynamic]ListEntry
for row in rows {
filename := filepath.base(row.Path)
append(&entries, ListEntry{
Directory = strings.concatenate({row.Dir, "/"}),
Path = filename,
})
}
table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width) data, marshal_err := json.marshal(entries[:])
} else { if marshal_err != nil {
// TODO: Should we instead print full entries here? fmt.printf("Error marshaling JSON: %v\n", marshal_err)
entries := make([dynamic]ListEntry, 0, len(rows), context.temp_allocator) return
for row in rows { }
filename := filepath.base(row.path) fmt.println(string(data))
append( }
&entries,
ListEntry {
dir = strings.concatenate(
{row.dir, os.Path_Separator_String},
context.temp_allocator,
),
path = filename,
},
)
}
data, marshal_err := json.marshal(entries[:], allocator = context.temp_allocator)
if marshal_err != nil {
fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return
}
fmt.wprintln(cmd.out, string(data), flush = false)
}
} }
+2 -99
View File
@@ -1,11 +1,6 @@
#+feature dynamic-literals
#+test
package main package main
import "core:bufio"
import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings"
import "core:testing" import "core:testing"
@(test) @(test)
@@ -15,101 +10,9 @@ test_filepath_base_equals_rel :: proc(t: ^testing.T) {
for path in cases { for path in cases {
dir := filepath.dir(path) dir := filepath.dir(path)
rel, rel_err := filepath.rel(dir, path, context.temp_allocator) rel, rel_err := filepath.rel(dir, path, context.temp_allocator)
testing.expect_value(t, rel_err, nil) testing.expect(t, rel_err == nil, "filepath.rel returned an error")
base := filepath.base(path) base := filepath.base(path)
testing.expect_value(t, rel, base) testing.expect(t, rel == base, "filepath.rel(dir, path) should equal filepath.base(path)")
} }
} }
@(test)
test_cmd_list_output_json :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-list-json-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join([]string{base, "config.json"}, context.temp_allocator)
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "list", "--output", "json", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_list(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "["), "json output should contain '['")
testing.expect(
t,
strings.contains(output, "\"directory\""),
"json output should contain directory key",
)
}
@(test)
test_cmd_list_output_table :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-list-table-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join([]string{base, "config.json"}, context.temp_allocator)
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "list", "--output", "table", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_list(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "│"), "table output should contain border chars")
testing.expect(
t,
strings.contains(output, "Directory"),
"table output should contain Directory header",
)
}
+9
View File
@@ -0,0 +1,9 @@
package main
import "core:fmt"
COMPLETION_SCRIPT: string : string(#load("mod.nu"))
cmd_nushell_completion :: proc(cmd: ^Command) {
fmt.print(COMPLETION_SCRIPT)
}
+36
View File
@@ -0,0 +1,36 @@
package main
import "core:fmt"
import "core:strings"
import "core:testing"
@(test)
test_nushell_completion_nonempty :: proc(t: ^testing.T) {
testing.expect(t, len(COMPLETION_SCRIPT) > 0, "completion script should not be empty")
}
@(test)
test_nushell_completion_contains_externs :: proc(t: ^testing.T) {
expected := []string{
"tracked-paths",
"untracked-paths",
"envr backup",
"envr check",
"envr edit-config",
"envr help",
"envr init",
"envr list",
"envr remove",
"envr restore",
"envr scan",
"envr sync",
"envr nushell-completion",
}
for ext in expected {
testing.expect(
t,
strings.contains(COMPLETION_SCRIPT, ext),
fmt.tprintf("expected script to contain %q", ext),
)
}
}
+29 -24
View File
@@ -5,33 +5,38 @@ import "core:path/filepath"
import "core:strings" import "core:strings"
cmd_remove :: proc(cmd: ^Command) { cmd_remove :: proc(cmd: ^Command) {
if len(cmd.args) != 1 { if len(cmd.args) != 1 {
print_command_help(cmd) print_command_help("remove")
return return
} }
path := cmd.args[0] path := cmd.args[0]
if len(strings.trim_space(path)) == 0 { if len(strings.trim_space(path)) == 0 {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false) fmt.println("Error: No path provided")
return return
} }
abs_path, abs_err := filepath.abs(path, context.temp_allocator) abs_path: string
if abs_err != nil { if filepath.is_abs(path) {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) abs_path = path
return } else {
} resolved, abs_err := filepath.abs(path)
if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err)
return
}
abs_path = resolved
}
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open()
if !db_ok { if !db_ok {
return return
} }
defer db_close(&db) defer db_close(&db)
if !db_delete(&db, abs_path) { if !db_delete(&db, abs_path) {
return return
} }
fmt.wprintf(cmd.out, "Removed %s from the database\n", abs_path, flush = false) fmt.printf("Removed %s from the database\n", abs_path)
} }
+37 -37
View File
@@ -6,48 +6,48 @@ import "core:path/filepath"
import "core:strings" import "core:strings"
cmd_restore :: proc(cmd: ^Command) { cmd_restore :: proc(cmd: ^Command) {
if len(cmd.args) != 1 { if len(cmd.args) != 1 {
print_command_help(cmd) print_command_help("restore")
return return
} }
path := cmd.args[0] path := cmd.args[0]
if len(strings.trim_space(path)) == 0 { if len(strings.trim_space(path)) == 0 {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false) fmt.println("Error: No path provided")
return return
} }
abs_path, abs_err := filepath.abs(path, context.temp_allocator)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return abs_path: string
} if filepath.is_abs(path) {
abs_path = path
} else {
resolved, abs_err := filepath.abs(path)
if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err)
return
}
abs_path = resolved
}
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open()
if !db_ok { if !db_ok {
return return
} }
defer db_close(&db) defer db_close(&db)
file, fetch_ok := db_fetch(&db, abs_path) file, fetch_ok := db_fetch(&db, abs_path)
if !fetch_ok { if !fetch_ok {
return return
} }
dir := filepath.dir(file.path) dir := filepath.dir(file.Path)
if err := os.mkdir_all(dir); err != nil { os.mkdir_all(dir)
fmt.wprintf(cmd.err, "Failed to create directory: %v\n", err, flush = false)
return write_err := os.write_entire_file(file.Path, file.contents)
} if write_err != nil {
fmt.printf("Error writing file: %v\n", write_err)
return
}
write_err := os.write_entire_file(file.path, file.contents) fmt.printf("Restored %s\n", file.Path)
if write_err != nil {
fmt.wprintf(cmd.err, "Error writing file: %v\n", write_err, flush = false)
return
}
fmt.wprintf(cmd.out, "Restored %s\n", file.path, flush = false)
} }
+22 -41
View File
@@ -2,38 +2,34 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:os"
import "core:terminal"
cmd_scan :: proc(cmd: ^Command) { cmd_scan :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.flags.config_file) feats := check_features()
if cant_scan(feats) {
fmt.println(
"Error: please install fd to use the scan command (https://github.com/sharkdp/fd)",
)
return
}
db, db_ok := db_open()
if !db_ok { if !db_ok {
return return
} }
defer db_close(&db) defer db_close(&db)
search_dirs := search_paths(db.cfg, context.temp_allocator) search_dirs := search_paths(db.cfg)
if len(search_dirs) == 0 { if len(search_dirs) == 0 {
fmt.wprintln( fmt.println("No search paths configured. Please run `envr init` or edit your config.")
cmd.err,
"No search paths configured. Please run `envr init -f` or edit your config.",
flush = false,
)
return return
} }
// TODO: Figure out a sane default // TODO: Figure out a sane default
// Can't use temp allocator becuase strings inside are copied to context.allocator all_files: [dynamic]string
all_files := make([dynamic]string)
defer {
for &f in all_files {delete(f)}
delete(all_files)
}
for dir in search_dirs { for dir in search_dirs {
found, scan_ok := scan_path(dir, db.cfg) found, scan_ok := scan_path(dir, db.cfg)
defer delete(found)
if !scan_ok { if !scan_ok {
fmt.wprintf(cmd.err, "Error scanning %s\n", dir, flush = false) fmt.printf("Error scanning %s\n", dir)
continue continue
} }
for f in found { for f in found {
@@ -49,29 +45,23 @@ cmd_scan :: proc(cmd: ^Command) {
files := find_unbacked(all_files[:], db_files[:]) files := find_unbacked(all_files[:], db_files[:])
if len(files) == 0 { if len(files) == 0 {
fmt.wprintln(cmd.out, "No .env files found to add.", flush = false) fmt.println("No .env files found to add.")
return return
} }
if !terminal.is_terminal(os.stdout) { if !is_tty() {
output, marshal_err := json.marshal(files[:]) output, marshal_err := json.marshal(files[:])
if marshal_err != nil { if marshal_err != nil {
fmt.wprintf( fmt.printf("Error marshaling files to JSON: %v\n", marshal_err)
cmd.err,
"Error marshaling files to JSON: %v\n",
marshal_err,
flush = false,
)
return return
} }
fmt.wprintln(cmd.out, string(output), flush = false) fmt.println(string(output))
return return
} }
selected, result := multi_select("Select .env files to backup:", files[:]) selected, result := multi_select("Select .env files to backup:", files[:])
defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.wprintln(cmd.out, colorize(.Message, "Cancelled."), flush = false) fmt.println("\x1b[2mCancelled.\x1b[0m")
return return
} }
@@ -80,31 +70,22 @@ cmd_scan :: proc(cmd: ^Command) {
if !selected[i] { if !selected[i] {
continue continue
} }
// TODO: Test cover this leak
env_file, ok := new_env_file(files[i]) env_file, ok := new_env_file(files[i])
defer delete_envfile(&env_file)
if !ok { if !ok {
fmt.wprintf(cmd.err, "Error reading %s\n", files[i], flush = false) fmt.printf("Error reading %s\n", files[i])
continue continue
} }
if !db_insert(&db, env_file) { if !db_insert(&db, env_file) {
fmt.wprintf(cmd.err, "Error adding %s\n", files[i], flush = false) fmt.printf("Error adding %s\n", files[i])
continue continue
} }
added_count += 1 added_count += 1
} }
if added_count > 0 { if added_count > 0 {
fmt.wprintln( fmt.printf("\x1b[1;32mSuccessfully added %d file(s) to backup.\x1b[0m\n", added_count)
cmd.out,
colorize(
.Sucess,
fmt.tprintf("Successfully added %d file(s) to backup.", added_count),
),
flush = false,
)
} else { } else {
fmt.wprintln(cmd.out, colorize(.Message, "No files were added."), flush = false) fmt.println("\x1b[2mNo files were added.\x1b[0m")
} }
} }
+55 -55
View File
@@ -2,16 +2,15 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:text/table" import "core:strings"
SyncEntry :: struct { SyncEntry :: struct {
path: string `json:"path"`, Path: string `json:"path"`,
status: string `json:"status"`, Status: string `json:"status"`,
} }
// TODO: Check for quiet failures.
cmd_sync :: proc(cmd: ^Command) { cmd_sync :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open()
if !db_ok { if !db_ok {
return return
} }
@@ -21,75 +20,76 @@ cmd_sync :: proc(cmd: ^Command) {
if !list_ok { if !list_ok {
return return
} }
defer delete(files)
results := make([]SyncEntry, len(files), context.temp_allocator) results: [dynamic]SyncEntry
for &file, i in files { for &file in files {
result, err := db_sync(&db, &file) old_path: string
old_path, _ = strings.clone(file.Path)
result, err_msg := db_sync(&db, &file)
status: string status: string
if err != .None { s := i32(result)
status = sync_error_message(err) is_error := (s & i32(SyncResult.Error)) != 0
} else if .BackedUp in result { is_backed := (s & i32(SyncResult.BackedUp)) != 0
status = .DirUpdated in result ? "Moved & Backed Up" : "Backed Up" is_restored := (s & i32(SyncResult.Restored)) != 0
} else if .Restored in result { is_dir_updated := (s & i32(SyncResult.DirUpdated)) != 0
status = .DirUpdated in result ? "Moved & Restored" : "Restored"
} else if .DirUpdated in result { if is_error {
if len(err_msg) > 0 {
status = err_msg
} else {
status = "error"
}
} else if is_backed {
status = "Backed Up"
if !db_insert(&db, file) {
return
}
} else if is_restored {
status = "Restored"
} else if is_dir_updated && !is_restored {
status = "Moved" status = "Moved"
} else { } else {
status = "OK" status = "OK"
} }
results[i] = SyncEntry { if is_dir_updated {
path = file.path, if !db_delete(&db, old_path) {
status = status, return
}
} }
if db_update_required(result) {
if !db_insert(&db, file) {
return
}
}
path_str, _ := strings.clone(file.Path)
status_str, _ := strings.clone(status)
append(&results, SyncEntry{Path = path_str, Status = status_str})
} }
if cmd.flags.output == .Table { if is_tty() {
t: table.Table headers := []string{"File", "Status"}
table.init(&t, context.temp_allocator, context.temp_allocator) table_rows := make([dynamic][]string, 0, len(results))
table.padding(&t, 1, 1)
table.aligned_header_of_values(
&t,
.Center,
colorize(.Table_Heading, "File"),
colorize(.Table_Heading, "Status"),
)
for res in results { for res in results {
table.row(&t, res.path, res.status) row_slice := make([]string, 2)
row_slice[0] = res.Path
row_slice[1] = res.Status
append(&table_rows, row_slice)
} }
table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width) render_table(headers, table_rows[:])
} else { } else {
data, marshal_err := json.marshal(results[:], allocator = context.temp_allocator) data, marshal_err := json.marshal(results[:])
if marshal_err != nil { if marshal_err != nil {
fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false) fmt.printf("Error marshaling JSON: %v\n", marshal_err)
return return
} }
fmt.wprintln(cmd.out, string(data), flush = false) fmt.println(string(data))
} }
} }
sync_error_message :: proc(e: SyncError) -> string {
switch e {
case .None:
return ""
case .DirMissing:
return "directory missing"
case .MultipleDirs:
return "multiple directories found"
case .GitRootFailed:
return "failed to find git roots"
case .WriteFailed:
return "failed to write file"
case .ReadFailed:
return "failed to read file"
case .DbFailed:
return "failed to update database"
}
return "unknown error"
}
-64
View File
@@ -1,64 +0,0 @@
package main
import "core:bufio"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:terminal"
cmd_uninstall :: proc(cmd: ^Command) {
envr_d := envr_dir(cmd.flags.config_file)
if !os.exists(envr_d) {
fmt.wprintln(cmd.out, "Nothing to remove.", flush = false)
return
}
entries, err := os.read_all_directory_by_path(envr_d, context.temp_allocator)
if err != nil {
fmt.wprintf(cmd.err, "Error reading %s: %v\n", envr_d, err, flush = false)
return
}
paths := make([]string, len(entries), context.temp_allocator)
for entry, i in entries {
paths[i], _ = filepath.join([]string{envr_d, entry.name}, context.temp_allocator)
}
fmt.wprintln(cmd.out, "This will remove:", flush = false)
for path in paths {
fmt.wprintf(cmd.out, " %s\n", path, flush = false)
}
fmt.wprintf(cmd.out, " %s\n", envr_d, flush = false)
if !cmd.flags.force {
if !terminal.is_terminal(os.stdin) {
fmt.wprintln(cmd.err, "Run with --force to skip confirmation.", flush = false)
return
}
fmt.wprintf(cmd.out, "\nAre you sure? [y/N] ", flush = false)
bufio.writer_flush(cmd.out_buf)
buf: [128]u8
n, _ := os.read(os.stdin, buf[:])
response := strings.trim_space(string(buf[:n]))
if response != "y" && response != "Y" {
fmt.wprintln(cmd.out, "Aborted.", flush = false)
return
}
}
remove_err := os.remove_all(envr_d)
if remove_err != nil {
fmt.wprintf(cmd.err, "Error removing %s: %v\n", envr_d, remove_err, flush = false)
return
}
for path in paths {
fmt.wprintf(cmd.out, "Removed %s\n", path, flush = false)
}
fmt.wprintf(cmd.out, "Removed %s\n", envr_d, flush = false)
}
-97
View File
@@ -1,97 +0,0 @@
#+test
package main
import "core:bufio"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:testing"
@(test)
test_cmd_uninstall_force :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-uninstall-*")
cfg_path, _ := filepath.join([]string{base, ".envr", "config.json"}, context.temp_allocator)
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
defer delete(f.remotes)
db_insert(&db, f)
db_close(&db)
envr_d := filepath.dir(cfg_path)
testing.expect(t, os.exists(envr_d), ".envr directory should exist before uninstall")
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "uninstall", "--force", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_uninstall(&cmd)
bufio.writer_flush(cmd.out_buf)
testing.expect(t, !os.exists(envr_d), ".envr directory should be removed")
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "Removed"), "output should list removed files")
testing.expect(
t,
strings.contains(output, "config.json"),
"output should mention config.json",
)
testing.expect(
t,
strings.contains(output, "data.envr"),
"output should mention data.envr",
)
}
@(test)
test_cmd_uninstall_nothing_to_remove :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-uninstall-empty-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join([]string{base, ".envr", "config.json"}, context.temp_allocator)
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "uninstall", "--force", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_uninstall(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
testing.expect(
t,
strings.contains(output, "Nothing to remove"),
"output should say nothing to remove",
)
}
-10
View File
@@ -1,10 +0,0 @@
package main
import "core:fmt"
VERSION :: #load("version.txt", string)
cmd_version :: proc(cmd: ^Command) {
fmt.wprintln(cmd.out, VERSION, flush = false)
}
-66
View File
@@ -1,66 +0,0 @@
package main
import "base:runtime"
import "core:strings"
import "core:terminal/ansi"
Color_Code :: enum {
Heading,
Command,
// Example
Flag,
Table_Heading,
Message,
Sucess,
Caret,
Option_Label,
}
// COLOR_EXAMPLE :: ansi.CSI + ansi.ITALIC + ansi.SGR
@(private = "file")
ANSI_RESET :: ansi.CSI + ansi.RESET + ansi.SGR
disable_color := false
colorize :: proc(
color: Color_Code,
text: string,
allocator := context.temp_allocator,
disable := disable_color,
) -> (
string,
runtime.Allocator_Error,
) #optional_allocator_error {
if disable {
return text, nil
} else {
return strings.concatenate(
{ansi.CSI, color_code(color), ansi.SGR, text, ANSI_RESET},
allocator,
)
}
}
@(private = "file")
color_code :: proc(code: Color_Code) -> string {
switch code {
case .Heading:
return ansi.BOLD + ";" + ansi.UNDERLINE + ";" + ansi.FG_BRIGHT_GREEN
case .Command:
return ansi.BOLD + ";" + ansi.FG_BRIGHT_CYAN
case .Flag:
return ansi.BOLD + ";" + ansi.FG_BRIGHT_WHITE
case .Table_Heading:
return ansi.FG_BRIGHT_GREEN
case .Message:
return ansi.FAINT
case .Sucess, .Caret:
return ansi.BOLD + ";" + ansi.FG_GREEN
case .Option_Label:
return ansi.BOLD + ";" + ansi.FG_CYAN
case:
panic("Unknown case")
}
}
+150 -176
View File
@@ -1,168 +1,88 @@
package main package main
import "base:runtime"
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
import "findr"
Config :: struct {
keys: [dynamic]SshKeyPair `json:"keys"`,
scan_config: ScanConfig `json:"scan"`,
config_path: string `json:"-"`,
}
SshKeyPair :: struct { SshKeyPair :: struct {
private: string `json:"private"`, Private: string `json:"private"`,
public: string `json:"public"`, Public: string `json:"public"`,
} }
ScanConfig :: struct { ScanConfig :: struct {
matcher: string `json:"matcher"`, Matcher: string `json:"matcher"`,
exclude: [dynamic]string `json:"exclude"`, Exclude: [dynamic]string `json:"exclude"`,
include: [dynamic]string `json:"include"`, Include: [dynamic]string `json:"include"`,
} }
load_config :: proc(config_path: string, allocator := context.allocator) -> (Config, bool) { Config :: struct {
// TODO: Should we use context.allocator + defer delete()? Keys: [dynamic]SshKeyPair `json:"keys"`,
data, read_err := os.read_entire_file_from_path(config_path, context.temp_allocator) ScanConfig: ScanConfig `json:"scan"`,
}
load_config :: proc() -> (Config, bool) {
home, home_err := os.user_home_dir(context.temp_allocator)
if home_err != nil {
fmt.printf("Error getting home dir: %v\n", home_err)
return Config{}, false
}
config_path, join_err := filepath.join([]string{home, ".envr", "config.json"})
if join_err != nil {
return Config{}, false
}
data, read_err := os.read_entire_file_from_path(config_path, context.allocator)
if read_err != nil { if read_err != nil {
fmt.eprintln("No config file found. Please run `envr init` to generate one.") fmt.println("No config file found. Please run `envr init` to generate one.")
return Config{}, false return Config{}, false
} }
cfg: Config cfg: Config
err := json.unmarshal(data, &cfg, .JSON5, allocator) err := json.unmarshal(data, &cfg)
if err != nil { if err != nil {
fmt.eprintf("Error parsing config: %v\n", err) fmt.printf("Error parsing config: %v\n", err)
return Config{}, false return Config{}, false
} }
cfg.config_path = config_path
return cfg, true return cfg, true
} }
default_config_path :: proc(home: string, allocator := context.allocator) -> string { delete_config :: proc(cfg: Config) {
path, err := filepath.join([]string{home, ".envr", "config.json"}, allocator) delete(cfg.Keys)
if err != nil { delete(cfg.ScanConfig.Exclude)
panic("Ran out of memory when building config path") delete(cfg.ScanConfig.Include)
} }
envr_dir :: proc() -> string {
home, _ := os.user_home_dir(context.allocator)
dir, _ := filepath.join([]string{home, ".envr"})
return dir
}
data_age_path :: proc() -> string {
dir := envr_dir()
path, _ := filepath.join([]string{dir, "data.age"})
return path return path
} }
delete_config :: proc(cfg: ^Config, allocator := context.allocator) {
for key in cfg.keys {
delete(key.private, allocator)
delete(key.public, allocator)
}
delete(cfg.keys)
delete(cfg.scan_config.matcher, allocator)
for exclude in cfg.scan_config.exclude {
delete(exclude, allocator)
}
delete(cfg.scan_config.exclude)
for include in cfg.scan_config.include {
delete(include, allocator)
}
delete(cfg.scan_config.include)
}
save_config :: proc(cfg: Config, force: bool = false) -> bool {
config_dir := envr_dir(cfg.config_path)
if !os.exists(config_dir) {
mkdir_err := os.make_directory(config_dir)
if mkdir_err != nil {
fmt.eprintf("Error creating %s directory: %v\n", config_dir, mkdir_err)
return false
}
}
if os.exists(cfg.config_path) && !force {
info, stat_err := os.stat(cfg.config_path, context.temp_allocator)
if stat_err == nil {
defer os.file_info_delete(info, context.temp_allocator)
if info.size > 0 {
fmt.eprintln("Config file already exists. Run again with --force to reinitialize.")
return false
}
}
}
data, marshal_err := json.marshal(
cfg,
{pretty = true, use_spaces = true, spaces = 2},
context.temp_allocator,
)
if marshal_err != nil {
fmt.eprintf("Error marshaling config: %v\n", marshal_err)
return false
}
write_err := os.write_entire_file(cfg.config_path, data)
if write_err != nil {
fmt.eprintf("Error writing config: %v\n", write_err)
return false
}
return true
}
// Caller is responsible for calling delete_config()
new_config :: proc(
private_key_paths: []string,
cfg_path: string = "~/.envr/config.json",
) -> Config {
keys := make([dynamic]SshKeyPair, 0, len(private_key_paths))
for priv in private_key_paths {
// TODO: Is this bad?
priv_key := strings.clone(priv)
pub := strings.concatenate([]string{priv_key, ".pub"})
append(&keys, SshKeyPair{private = priv_key, public = pub})
}
// If we don't clone the strings, the cleanup semantics differ for Db created
// configs vs user created configs.
exclude := make([dynamic]string, 0, 4)
append(&exclude, strings.clone("*\\.envrc"))
append(&exclude, strings.clone("\\.local/"))
append(&exclude, strings.clone("node_modules"))
append(&exclude, strings.clone("vendor"))
include := make([dynamic]string, 0, 1)
append(&include, strings.clone("~"))
scan_cfg := ScanConfig {
matcher = strings.clone("\\.env"),
exclude = exclude,
include = include,
}
return Config{keys = keys, scan_config = scan_cfg, config_path = cfg_path}
}
find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) { find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
home, home_err := os.user_home_dir(context.allocator) home, home_err := os.user_home_dir(context.allocator)
if home_err != nil { if home_err != nil {
fmt.eprintf("Error getting home dir: %v\n", home_err) fmt.printf("Error getting home dir: %v\n", home_err)
return return
} }
ssh_dir, join_err := filepath.join([]string{home, ".ssh"}) ssh_dir, join_err := filepath.join([]string{home, ".ssh"})
if join_err != nil { if join_err != nil {
fmt.eprintf("Error building ssh path: %v\n", join_err) fmt.printf("Error building ssh path: %v\n", join_err)
return return
} }
entries, dir_err := os.read_all_directory_by_path(ssh_dir, context.allocator) entries, dir_err := os.read_all_directory_by_path(ssh_dir, context.allocator)
if dir_err != nil { if dir_err != nil {
fmt.eprintf("Could not read ~/.ssh directory: %v\n", dir_err) fmt.printf("Could not read ~/.ssh directory: %v\n", dir_err)
return return
} }
defer os.file_info_slice_delete(entries, context.allocator) defer os.file_info_slice_delete(entries, context.allocator)
@@ -183,9 +103,6 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
} }
full_path, _ := filepath.join([]string{ssh_dir, name}) full_path, _ := filepath.join([]string{ssh_dir, name})
if !is_ed25519_key(full_path) {
continue
}
append(&keys, full_path) append(&keys, full_path)
} }
@@ -193,57 +110,114 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
return return
} }
find_git_roots :: proc( new_config :: proc(private_key_paths: []string) -> Config {
cfg: Config, keys := make([dynamic]SshKeyPair, 0, len(private_key_paths))
allocator := context.temp_allocator, for priv in private_key_paths {
) -> ( // TODO: Is this bad?
roots: [dynamic]string, pub, _ := strings.concatenate([]string{priv, ".pub"}, context.temp_allocator)
ok: bool, append(&keys, SshKeyPair{Private = priv, Public = pub})
) { }
paths := search_paths(cfg, allocator)
findr.find_repos(paths[:], &roots, os.get_processor_core_count(), allocator) exclude := make([dynamic]string, 0, 4)
append(&exclude, "*\\.envrc")
append(&exclude, "\\.local/")
append(&exclude, "node_modules")
append(&exclude, "vendor")
include := make([dynamic]string, 0, 1)
append(&include, "~")
scan_cfg := ScanConfig {
Matcher = "\\.env",
Exclude = exclude,
Include = include,
}
return Config{Keys = keys, ScanConfig = scan_cfg}
}
save_config :: proc(cfg: Config, force: bool = false) -> bool {
home, home_err := os.user_home_dir(context.allocator)
if home_err != nil {
fmt.printf("Error getting home dir: %v\n", home_err)
return false
}
config_dir, _ := filepath.join([]string{home, ".envr"})
if !os.exists(config_dir) {
mkdir_err := os.make_directory(config_dir)
if mkdir_err != nil {
fmt.printf("Error creating ~/.envr directory: %v\n", mkdir_err)
return false
}
}
config_path, _ := filepath.join([]string{config_dir, "config.json"})
if os.exists(config_path) && !force {
info, stat_err := os.stat(config_path, context.allocator)
if stat_err == nil {
defer os.file_info_delete(info, context.allocator)
if info.size > 0 {
fmt.println("Config file already exists. Run again with --force to reinitialize.")
return false
}
}
}
data, marshal_err := json.marshal(cfg, {pretty = true, use_spaces = true, spaces = 2})
if marshal_err != nil {
fmt.printf("Error marshaling config: %v\n", marshal_err)
return false
}
write_err := os.write_entire_file(config_path, data)
if write_err != nil {
fmt.printf("Error writing config: %v\n", write_err)
return false
}
return true
}
search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) {
home, _ := os.user_home_dir(context.allocator)
for include in cfg.ScanConfig.Include {
expanded, _ := strings.replace(include, "~", home, 1)
cloned, _ := strings.clone(expanded)
if filepath.is_abs(cloned) {
append(&paths, cloned)
} else {
resolved, err := filepath.abs(cloned)
if err == nil {
append(&paths, resolved)
}
}
}
return
}
find_git_roots :: proc(cfg: Config) -> (roots: [dynamic]string, ok: bool) {
paths := search_paths(cfg)
for sp in paths {
args := []string{"fd", "-H", "-t", "d", "^\\.git$", sp}
lines, fd_ok := run_fd(args)
if !fd_ok {
return
}
for line in lines {
cleaned, _ := filepath.clean(line)
parent := filepath.dir(cleaned)
cloned, _ := strings.clone(parent)
append(&roots, cloned)
}
}
ok = true ok = true
return return
} }
search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]string {
home, err := os.user_home_dir(context.temp_allocator)
if err != nil {
panic("Failed to find home directory")
}
paths := new_clone(cfg.scan_config.include, allocator)
defer free(paths, allocator)
for &include in paths {
expanded, _ := strings.replace(include, "~", home, 1, allocator)
if filepath.is_abs(expanded) {
include = expanded
} else {
// TODO: show errors?
resolved: string
resolved, err = filepath.abs(expanded, allocator)
if err == nil {
include = resolved
}
}
}
result := paths^
return result
}
// User is responsible for freeing the path
data_path :: proc(
config_path: string,
allocator := context.allocator,
) -> (
string,
runtime.Allocator_Error,
) #optional_allocator_error {
return filepath.join([]string{envr_dir(config_path), "data.envr"}, allocator)
}
envr_dir :: proc(config_path: string) -> string {
return filepath.dir(config_path)
}
+21 -166
View File
@@ -1,208 +1,63 @@
#+test
package main package main
import "core:fmt"
import "core:mem"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:sync"
import "core:testing" import "core:testing"
home_mutex: sync.Mutex
@(test) @(test)
test_new_config_single_key :: proc(t: ^testing.T) { test_new_config_single_key :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"} paths := []string{"/home/user/.ssh/id_ed25519"}
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(&cfg) defer delete_config(cfg)
testing.expect_value(t, len(cfg.keys), 1) testing.expect(t, len(cfg.Keys) == 1, "should have 1 key")
testing.expect_value(t, cfg.keys[0].private, "/home/user/.ssh/id_ed25519") testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519", "Private path mismatch")
testing.expect_value(t, cfg.keys[0].public, "/home/user/.ssh/id_ed25519.pub") testing.expect(
t,
cfg.Keys[0].Public == "/home/user/.ssh/id_ed25519.pub",
"Public path mismatch",
)
} }
@(test) @(test)
test_new_config_multiple_keys :: proc(t: ^testing.T) { test_new_config_multiple_keys :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519", "/home/user/.ssh/id_rsa"} paths := []string{"/home/user/.ssh/id_ed25519", "/home/user/.ssh/id_rsa"}
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(&cfg) defer delete_config(cfg)
testing.expect_value(t, len(cfg.keys), 2) testing.expect(t, len(cfg.Keys) == 2, "should have 2 keys")
testing.expect_value(t, cfg.keys[0].private, "/home/user/.ssh/id_ed25519") testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519")
testing.expect_value(t, cfg.keys[1].private, "/home/user/.ssh/id_rsa") testing.expect(t, cfg.Keys[1].Private == "/home/user/.ssh/id_rsa")
} }
@(test) @(test)
test_new_config_empty_keys :: proc(t: ^testing.T) { test_new_config_empty_keys :: proc(t: ^testing.T) {
paths: []string paths: []string
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(&cfg) defer delete_config(cfg)
testing.expect_value(t, len(cfg.keys), 0) testing.expect(t, len(cfg.Keys) == 0, "should have 0 keys")
} }
@(test) @(test)
test_new_config_scan_defaults :: proc(t: ^testing.T) { test_new_config_scan_defaults :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"} paths := []string{"/home/user/.ssh/id_ed25519"}
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(&cfg) defer delete_config(cfg)
testing.expect_value(t, cfg.scan_config.matcher, "\\.env") testing.expect(t, cfg.ScanConfig.Matcher == "\\.env", "matcher should be \\.env")
testing.expect_value(t, len(cfg.scan_config.exclude), 4) testing.expect(t, len(cfg.ScanConfig.Exclude) == 4, "should have 4 exclude patterns")
testing.expect_value(t, len(cfg.scan_config.include), 1) testing.expect(t, len(cfg.ScanConfig.Include) == 1, "should have 1 include path")
testing.expect_value(t, cfg.scan_config.include[0], "~") testing.expect(t, cfg.ScanConfig.Include[0] == "~", "include should be ~")
} }
@(test) @(test)
test_new_config_exclude_patterns :: proc(t: ^testing.T) { test_new_config_exclude_patterns :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"} paths := []string{"/home/user/.ssh/id_ed25519"}
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(&cfg) defer delete_config(cfg)
expected := []string{"*\\.envrc", "\\.local/", "node_modules", "vendor"} expected := []string{"*\\.envrc", "\\.local/", "node_modules", "vendor"}
for i in 0 ..< len(expected) { for i in 0 ..< len(expected) {
testing.expect_value(t, cfg.scan_config.exclude[i], expected[i]) testing.expect(t, cfg.ScanConfig.Exclude[i] == expected[i])
} }
} }
@(test)
test_save_load_config_roundtrip :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-cfg-rt-*")
defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect_value(t, err, nil)
cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}, cfgPath)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
loaded, ok := load_config(cfg.config_path)
testing.expect(t, ok, "load should succeed")
if !ok do return
defer delete_config(&loaded)
testing.expect_value(t, len(loaded.keys), 1)
testing.expect_value(t, loaded.keys[0].private, "/home/user/.ssh/id_ed25519")
testing.expect_value(t, loaded.keys[0].public, "/home/user/.ssh/id_ed25519.pub")
testing.expect_value(t, loaded.scan_config.matcher, "\\.env")
testing.expect_value(t, len(loaded.scan_config.exclude), 4)
testing.expect_value(t, len(loaded.scan_config.include), 1)
testing.expect_value(t, loaded.scan_config.include[0], "~")
}
@(test)
test_load_config_missing :: proc(t: ^testing.T) {
_, ok := load_config("/tmp/envr-test-cfg-nonexistent/config.json")
testing.expect(t, !ok, "missing config should return false")
}
@(test)
test_save_config_no_clobber :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-cfg-noclobber-*")
defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect_value(t, err, nil)
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "first save should succeed")
cfg2 := new_config([]string{"/home/user/.ssh/key2"}, cfgPath)
defer delete_config(&cfg2)
testing.expect(t, !save_config(cfg2), "second save without force should fail")
}
@(test)
test_save_config_force_overwrites :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-cfg-force-*")
defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect_value(t, err, nil)
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "first save should succeed")
cfg2 := new_config([]string{"/home/user/.ssh/key2"}, cfgPath)
defer delete_config(&cfg2)
testing.expect(t, save_config(cfg2, force = true), "force save should overwrite")
loaded, ok := load_config(cfgPath)
testing.expect(t, ok, "load should succeed")
if !ok do return
defer delete_config(&loaded)
testing.expect_value(t, len(loaded.keys), 1)
testing.expect_value(t, loaded.keys[0].private, "/home/user/.ssh/key2")
}
@(test)
test_envr_dir :: proc(t: ^testing.T) {
dir := envr_dir("/tmp/envr-fake-home-envrdir/.envr/config.json")
testing.expectf(t, strings.has_suffix(dir, ".envr"), "dir should end with .envr, got %s", dir)
testing.expectf(
t,
strings.contains(dir, "envr-fake-home-envrdir"),
"dir should contain home dir, got %s",
dir,
)
}
@(test)
test_data_path :: proc(t: ^testing.T) {
p := data_path("/tmp/envr-fake-home-datapath/config.json")
defer delete(p)
testing.expectf(t, strings.has_suffix(p, "data.envr"), "should end with data.envr, got %s", p)
testing.expectf(t, strings.contains(p, ".envr"), "should contain .envr dir, got %s", p)
}
@(test)
test_search_paths_expands_tilde :: proc(t: ^testing.T) {
sync.mutex_lock(&home_mutex)
defer sync.mutex_unlock(&home_mutex)
old_home := os.get_env("HOME", context.temp_allocator)
defer {
if old_home != "" {
os.set_env("HOME", old_home)
}
}
os.set_env("HOME", "/tmp/envr-fake-home-search")
cfg := Config {
scan_config = ScanConfig{include = make([dynamic]string, 0, 1)},
}
append(&cfg.scan_config.include, "~")
defer delete(cfg.scan_config.include)
paths := search_paths(cfg, context.temp_allocator)
testing.expect_value(t, len(paths), 1)
if len(paths) > 0 {
testing.expectf(
t,
strings.contains(paths[0], "envr-fake-home-search"),
"should expand ~ to home, got %s",
paths[0],
)
testing.expect(t, !strings.contains(paths[0], "~"), "should not contain literal ~")
}
}
@(test)
test_search_paths_no_leak :: proc(t: ^testing.T) {
cfg := Config {
scan_config = ScanConfig{include = make([dynamic]string, 0, 1)},
}
defer delete(cfg.scan_config.include)
append(&cfg.scan_config.include, "/tmp")
_ = search_paths(cfg, context.allocator)
}
-325
View File
@@ -1,325 +0,0 @@
package main
import "core:fmt"
import "core:mem"
import "core:os"
MAGIC :: "ENVR"
MAGIC_BYTES: [4]u8 = MAGIC
RECIPIENT_ENTRY_SIZE ::
CRYPTO_BOX_PUBLICKEY_BYTES +
CRYPTO_BOX_NONCE_BYTES +
CRYPTO_SECRETBOX_KEY_BYTES +
CRYPTO_BOX_MAC_BYTES
HEADER_SIZE :: 4 + CRYPTO_BOX_PUBLICKEY_BYTES + CRYPTO_SECRETBOX_NONCE_BYTES + 4
RecipientEntry :: struct {
PublicKey: [CRYPTO_BOX_PUBLICKEY_BYTES]u8,
Nonce: [CRYPTO_BOX_NONCE_BYTES]u8,
EncryptedKey: [CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES]u8,
}
X25519Keypair :: struct {
Public: [CRYPTO_BOX_PUBLICKEY_BYTES]u8,
Private: [CRYPTO_BOX_SECRETKEY_BYTES]u8,
}
@(init)
init_sodium :: proc "contextless" () {
if sodium_init() < 0 {
os.exit(1)
}
}
// TODO: Optimize performance
encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: bool) {
x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator)
if !pairs_ok {
return
}
sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8
randombytes_buf(&sym_key[0], CRYPTO_SECRETBOX_KEY_BYTES)
main_nonce: [CRYPTO_SECRETBOX_NONCE_BYTES]u8
randombytes_buf(&main_nonce[0], CRYPTO_SECRETBOX_NONCE_BYTES)
ct_len := len(plaintext) + CRYPTO_SECRETBOX_MAC_BYTES
secret_ct := make([]u8, ct_len, context.temp_allocator)
pt_ptr: [^]u8
if len(plaintext) > 0 {
pt_ptr = &plaintext[0]
}
rc := crypto_secretbox_easy(
&secret_ct[0],
pt_ptr,
u64(len(plaintext)),
&main_nonce[0],
&sym_key[0],
)
if rc != 0 {
fmt.eprintln("Error: symmetric encryption failed")
delete(secret_ct)
return
}
num_recipients := u32(len(x25519_pairs))
entries := make([]RecipientEntry, num_recipients, context.temp_allocator)
for i in 0 ..< len(x25519_pairs) {
for j in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
entries[i].PublicKey[j] = x25519_pairs[i].Public[j]
}
randombytes_buf(&entries[i].Nonce[0], CRYPTO_BOX_NONCE_BYTES)
rc = crypto_box_easy(
&entries[i].EncryptedKey[0],
&sym_key[0],
CRYPTO_SECRETBOX_KEY_BYTES,
&entries[i].Nonce[0],
&x25519_pairs[i].Public[0],
&x25519_pairs[0].Private[0],
)
if rc != 0 {
fmt.eprintf("Error: failed to encrypt for recipient %d\n", i)
delete(entries)
delete(secret_ct)
return
}
}
total_len := HEADER_SIZE + int(num_recipients) * RECIPIENT_ENTRY_SIZE + ct_len
ciphertext = make([]u8, total_len)
pos := 0
mem.copy(&ciphertext[pos], &MAGIC_BYTES[0], 4)
pos += 4
mem.copy(&ciphertext[pos], &x25519_pairs[0].Public[0], CRYPTO_BOX_PUBLICKEY_BYTES)
pos += CRYPTO_BOX_PUBLICKEY_BYTES
mem.copy(&ciphertext[pos], &main_nonce[0], CRYPTO_SECRETBOX_NONCE_BYTES)
pos += CRYPTO_SECRETBOX_NONCE_BYTES
ciphertext[pos] = u8((num_recipients >> 24) & 0xff)
ciphertext[pos + 1] = u8((num_recipients >> 16) & 0xff)
ciphertext[pos + 2] = u8((num_recipients >> 8) & 0xff)
ciphertext[pos + 3] = u8(num_recipients & 0xff)
pos += 4
for i in 0 ..< int(num_recipients) {
mem.copy(&ciphertext[pos], &entries[i].PublicKey[0], CRYPTO_BOX_PUBLICKEY_BYTES)
pos += CRYPTO_BOX_PUBLICKEY_BYTES
mem.copy(&ciphertext[pos], &entries[i].Nonce[0], CRYPTO_BOX_NONCE_BYTES)
pos += CRYPTO_BOX_NONCE_BYTES
mem.copy(
&ciphertext[pos],
&entries[i].EncryptedKey[0],
CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES,
)
pos += CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES
}
mem.copy(&ciphertext[pos], &secret_ct[0], ct_len)
ok = true
return
}
decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: bool) {
if len(ciphertext) < HEADER_SIZE {
fmt.eprintln("Error: ciphertext too short (header)")
return
}
for i in 0 ..< 4 {
if ciphertext[i] != MAGIC_BYTES[i] {
fmt.eprintln("Error: invalid magic bytes")
return
}
}
offset := 4
sender_pk: [CRYPTO_BOX_PUBLICKEY_BYTES]u8
for i in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
sender_pk[i] = ciphertext[offset + i]
}
offset += CRYPTO_BOX_PUBLICKEY_BYTES
main_nonce: [CRYPTO_SECRETBOX_NONCE_BYTES]u8
for i in 0 ..< CRYPTO_SECRETBOX_NONCE_BYTES {
main_nonce[i] = ciphertext[offset + i]
}
offset += CRYPTO_SECRETBOX_NONCE_BYTES
num_recipients :=
u32(ciphertext[offset]) << 24 |
u32(ciphertext[offset + 1]) << 16 |
u32(ciphertext[offset + 2]) << 8 |
u32(ciphertext[offset + 3])
offset += 4
recipients_end := offset + int(num_recipients) * RECIPIENT_ENTRY_SIZE
if recipients_end > len(ciphertext) {
fmt.eprintln("Error: ciphertext too short (recipient data)")
return
}
enc_sym_key: [CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES]u8
enc_nonce: [CRYPTO_BOX_NONCE_BYTES]u8
enc_pub: [CRYPTO_BOX_PUBLICKEY_BYTES]u8
x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator)
if !pairs_ok {
return
}
found := false
matched_pi := 0
for pi in 0 ..< len(x25519_pairs) {
scan_offset := offset
for _ in 0 ..< int(num_recipients) {
for i in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
enc_pub[i] = ciphertext[scan_offset + i]
}
scan_offset += CRYPTO_BOX_PUBLICKEY_BYTES
match := true
for i in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
if enc_pub[i] != x25519_pairs[pi].Public[i] {
match = false
break
}
}
if !match {
scan_offset +=
CRYPTO_BOX_NONCE_BYTES + CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES
continue
}
for i in 0 ..< CRYPTO_BOX_NONCE_BYTES {
enc_nonce[i] = ciphertext[scan_offset + i]
}
scan_offset += CRYPTO_BOX_NONCE_BYTES
for i in 0 ..< CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES {
enc_sym_key[i] = ciphertext[scan_offset + i]
}
scan_offset += CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES
found = true
matched_pi = pi
break
}
if found {
break
}
}
if !found {
fmt.eprintln("Error: no matching recipient found")
return
}
sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8
rc := crypto_box_open_easy(
&sym_key[0],
&enc_sym_key[0],
CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES,
&enc_nonce[0],
&sender_pk[0],
&x25519_pairs[matched_pi].Private[0],
)
if rc != 0 {
fmt.eprintln("Error: failed to decrypt symmetric key")
return
}
ct_data := ciphertext[recipients_end:]
pt_len := len(ct_data) - CRYPTO_SECRETBOX_MAC_BYTES
if pt_len < 0 {
fmt.eprintln("Error: ciphertext too short (no encrypted data)")
return
}
plaintext = make([]u8, pt_len)
pt_ptr: [^]u8
if len(plaintext) > 0 {
pt_ptr = &plaintext[0]
}
rc = crypto_secretbox_open_easy(
pt_ptr,
&ct_data[0],
u64(len(ct_data)),
&main_nonce[0],
&sym_key[0],
)
if rc != 0 {
fmt.eprintln("Error: symmetric decryption failed")
delete(plaintext)
return
}
ok = true
return
}
ssh_to_x25519 :: proc(
keys: []SshKeyPair,
allocator := context.temp_allocator,
) -> (
[]X25519Keypair,
bool,
) {
if len(keys) == 0 {
return {}, false
}
pairs := make([]X25519Keypair, len(keys), allocator)
for i in 0 ..< len(keys) {
ssh_kp, parse_ok := parse_ssh_private_key(keys[i].private)
if !parse_ok {
fmt.eprintf("Error: failed to parse SSH private key: %s\n", keys[i].private)
delete(pairs)
return pairs, false
}
ssh_pub, pub_ok := parse_ssh_public_key(keys[i].public)
if !pub_ok {
fmt.eprintf("Error: failed to parse SSH public key: %s\n", keys[i].public)
delete(pairs)
return pairs, false
}
pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0])
if pk_rc != 0 {
fmt.eprintln("Error: failed to convert ed25519 public key to curve25519")
delete(pairs)
return pairs, false
}
ed25519_sk: [64]u8
for j in 0 ..< 32 {
ed25519_sk[j] = ssh_kp.Private[j]
}
for j in 0 ..< 32 {
ed25519_sk[32 + j] = ssh_kp.Public[j]
}
sk_rc := crypto_sign_ed25519_sk_to_curve25519(&pairs[i].Private[0], &ed25519_sk[0])
if sk_rc != 0 {
fmt.eprintln("Error: failed to convert ed25519 private key to curve25519")
delete(pairs)
return pairs, false
}
}
return pairs, true
}
-126
View File
@@ -1,126 +0,0 @@
#+test
package main
import "core:fmt"
import "core:os"
import "core:testing"
CRYPTO_TEST_KEY_DIR :: "fixtures" + os.Path_Separator_String + "keys"
make_test_key_pair :: proc(name: string) -> SshKeyPair {
priv := fmt.tprintf("%s/%s", CRYPTO_TEST_KEY_DIR, name)
pub := fmt.tprintf("%s/%s.pub", CRYPTO_TEST_KEY_DIR, name)
return SshKeyPair{private = priv, public = pub}
}
@(test)
test_encrypt_decrypt_roundtrip :: proc(t: ^testing.T) {
key := make_test_key_pair("test_ed25519")
original := []u8{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key})
testing.expect(t, enc_ok, "encryption should succeed")
testing.expect(t, len(encrypted) > 0, "ciphertext should not be empty")
defer delete(encrypted)
decrypted, dec_ok := decrypt(encrypted, []SshKeyPair{key})
testing.expect(t, dec_ok, "decryption should succeed")
defer delete(decrypted)
testing.expect_value(t, len(decrypted), len(original))
// TODO: Should this be a loop?
for i in 0 ..< len(original) {
testing.expect_value(t, decrypted[i], original[i])
}
}
@(test)
test_encrypt_decrypt_multi_recipient :: proc(t: ^testing.T) {
key1 := make_test_key_pair("test_ed25519")
key2 := make_test_key_pair("test_ed25519_second")
original := []u8{42, 43, 44, 45}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key1, key2})
testing.expect(t, enc_ok, "encryption with 2 keys should succeed")
defer delete(encrypted)
decrypted1, dec1_ok := decrypt(encrypted, []SshKeyPair{key1})
testing.expect(t, dec1_ok, "decryption with key1 should succeed")
defer delete(decrypted1)
decrypted2, dec2_ok := decrypt(encrypted, []SshKeyPair{key2})
testing.expect(t, dec2_ok, "decryption with key2 should succeed")
defer delete(decrypted2)
for i in 0 ..< len(original) {
testing.expect_value(t, decrypted1[i], original[i])
testing.expect_value(t, decrypted2[i], original[i])
}
}
@(test)
test_decrypt_wrong_key_fails :: proc(t: ^testing.T) {
key1 := make_test_key_pair("test_ed25519")
key2 := make_test_key_pair("test_ed25519_second")
original := []u8{1, 2, 3}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key1})
testing.expect(t, enc_ok, "encryption should succeed")
defer delete(encrypted)
_, dec_ok := decrypt(encrypted, []SshKeyPair{key2})
testing.expect(t, !dec_ok, "decryption with wrong key should fail")
}
@(test)
test_encrypt_empty_plaintext :: proc(t: ^testing.T) {
key := make_test_key_pair("test_ed25519")
original: []u8
encrypted, enc_ok := encrypt(original, []SshKeyPair{key})
testing.expect(t, enc_ok, "encryption of empty data should succeed")
defer delete(encrypted)
decrypted, dec_ok := decrypt(encrypted, []SshKeyPair{key})
testing.expect(t, dec_ok, "decryption should succeed")
defer delete(decrypted)
testing.expect_value(t, len(decrypted), 0)
}
@(test)
test_recipient_can_decrypt_senders_data :: proc(t: ^testing.T) {
key1 := make_test_key_pair("test_ed25519")
key2 := make_test_key_pair("test_ed25519_second")
original := []u8{10, 20, 30, 40, 50}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key1, key2})
testing.expect(t, enc_ok, "encryption with 2 keys should succeed")
defer delete(encrypted)
decrypted, dec_ok := decrypt(encrypted, []SshKeyPair{key2})
testing.expect(t, dec_ok, "second recipient should decrypt without the sender key present")
defer delete(decrypted)
// TODO: Should this be a loop?
for i in 0 ..< len(original) {
testing.expect_value(t, decrypted[i], original[i])
}
}
@(test)
test_ciphertext_has_magic :: proc(t: ^testing.T) {
key := make_test_key_pair("test_ed25519")
original := []u8{1, 2, 3}
encrypted, enc_ok := encrypt(original, []SshKeyPair{key})
testing.expect(t, enc_ok, "encryption should succeed")
defer delete(encrypted)
testing.expect(t, len(encrypted) >= 4, "ciphertext should have at least 4 bytes")
testing.expect_value(t, encrypted[0], u8('E'))
testing.expect_value(t, encrypted[1], u8('N'))
testing.expect_value(t, encrypted[2], u8('V'))
testing.expect_value(t, encrypted[3], u8('R'))
}
+509 -468
View File
File diff suppressed because it is too large Load Diff
-335
View File
@@ -1,335 +0,0 @@
#+test
package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:testing"
import "sqlite"
FIXTURES :: "fixtures"
test_temp_dir :: proc(t: ^testing.T, prefix: string) -> string {
dir, err := os.mkdir_temp("", prefix, context.temp_allocator)
if err != nil {
testing.fail_now(t, fmt.tprintf("Failed to create temp dir: %v", err))
}
return dir
}
fixture_key :: proc() -> SshKeyPair {
priv, _ := strings.concatenate(
[]string{FIXTURES, "/keys/insecure-test-key"},
context.temp_allocator,
)
pub, _ := strings.concatenate(
[]string{FIXTURES, "/keys/insecure-test-key.pub"},
context.temp_allocator,
)
return SshKeyPair{private = priv, public = pub}
}
fixture_db_path :: proc() -> string {
p, _ := strings.concatenate([]string{FIXTURES, "/single-file.db"}, context.temp_allocator)
return p
}
fixture_config :: proc() -> Config {
cfg := Config {
keys = make([dynamic]SshKeyPair, 0, 1),
}
append(&cfg.keys, fixture_key())
return cfg
}
@(test)
test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.keys)
}
db_path := fixture_db_path()
sqlite_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err)
if read_err != nil {
return
}
defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.keys[:])
testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
testing.expect(t, len(encrypted) >= HEADER_SIZE, "ciphertext should have header")
testing.expect_value(t, encrypted[0], u8('E'))
testing.expect_value(t, encrypted[1], u8('N'))
testing.expect_value(t, encrypted[2], u8('V'))
testing.expect_value(t, encrypted[3], u8('R'))
plaintext, dec_ok := decrypt(encrypted, cfg.keys[:])
testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
testing.expectf(
t,
len(plaintext) == len(sqlite_data),
"round-trip size mismatch: expected %d, got %d",
len(sqlite_data),
len(plaintext),
)
match := true
for i in 0 ..< len(sqlite_data) {
if plaintext[i] != sqlite_data[i] {
match = false
break
}
}
testing.expect(t, match, "decrypted data should match original")
}
@(test)
test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.keys)
}
db_path := fixture_db_path()
sqlite_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err)
if read_err != nil {
return
}
defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.keys[:])
testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
ewrd_dir := test_temp_dir(t, "envr-test-ewrd-*")
defer os.remove_all(ewrd_dir)
tmp_enc_path, _ := filepath.join([]string{ewrd_dir, "data.envr"}, context.temp_allocator)
write_err := os.write_entire_file(tmp_enc_path, encrypted)
testing.expectf(t, write_err == nil, "failed to write encrypted file: %v", write_err)
if write_err != nil {
return
}
read_back, rb_err := os.read_entire_file_from_path(tmp_enc_path, context.allocator)
testing.expectf(t, rb_err == nil, "failed to read back encrypted file: %v", rb_err)
if rb_err != nil {
return
}
defer delete(read_back)
plaintext, dec_ok := decrypt(read_back, cfg.keys[:])
testing.expect(t, dec_ok, "decryption after write/read should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
testing.expect_value(t, len(plaintext), len(sqlite_data))
}
@(test)
test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.keys)
}
db_path := fixture_db_path()
sqlite_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err)
if read_err != nil {
return
}
defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.keys[:])
testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
plaintext, dec_ok := decrypt(encrypted, cfg.keys[:])
testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
mem_db: sqlite.Db
rc := sqlite.open(":memory:", &mem_db)
testing.expectf(t, rc == sqlite.OK, "failed to open in-memory db")
if rc != sqlite.OK {
return
}
defer sqlite.close(mem_db)
n := i64(len(plaintext))
buf := sqlite.malloc64(n)
testing.expect(t, buf != nil, "malloc64 should succeed")
if buf == nil do return
copy(buf[:len(plaintext)], plaintext)
rc = sqlite.deserialize(mem_db, "main", buf, n, n, {.FREEONCLOSE, .RESIZEABLE})
testing.expect_value(t, rc, sqlite.OK)
if rc != sqlite.OK {
sqlite.free(buf)
return
}
sql: cstring = "SELECT path FROM envr_env_files"
stmt: sqlite.Stmt
rc = sqlite.prepare_v2(mem_db, sql, -1, &stmt, nil)
testing.expect_value(t, rc, sqlite.OK)
if rc != sqlite.OK {
return
}
defer sqlite.finalize(stmt)
rc = sqlite.step(stmt)
testing.expect_value(t, rc, sqlite.ROW)
if rc == sqlite.ROW {
path := string(sqlite.column_text(stmt, 0))
testing.expect(t, len(path) > 0, "path should not be empty")
}
}
@(test)
test_full_db_cycle :: proc(t: ^testing.T) {
cfg := fixture_config()
defer delete(cfg.keys)
db_path := fixture_db_path()
original_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
testing.expectf(t, read_err == nil, "failed to read fixture db: %v", read_err)
if read_err != nil {
return
}
defer delete(original_data)
encrypted, enc_ok := encrypt(original_data, cfg.keys[:])
testing.expect(t, enc_ok, "first encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
cycle_dir := test_temp_dir(t, "envr-test-cycle-*")
defer os.remove_all(cycle_dir)
envr_dir_path, _ := filepath.join([]string{cycle_dir, ".envr"}, context.temp_allocator)
{
err := os.mkdir_all(envr_dir_path)
testing.expect_value(t, err, nil)
}
data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"}, context.temp_allocator)
write_err := os.write_entire_file(data_path, encrypted)
testing.expectf(t, write_err == nil, "failed to write data.envr: %v", write_err)
if write_err != nil {
return
}
read_back, rb_err := os.read_entire_file_from_path(data_path, context.allocator)
testing.expectf(t, rb_err == nil, "failed to read data.envr: %v", rb_err)
if rb_err != nil {
return
}
defer delete(read_back)
plaintext, dec_ok := decrypt(read_back, cfg.keys[:])
testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
encrypted2, enc2_ok := encrypt(plaintext, cfg.keys[:])
testing.expect(t, enc2_ok, "re-encryption should succeed")
if !enc2_ok {
return
}
defer delete(encrypted2)
plaintext2, dec2_ok := decrypt(encrypted2, cfg.keys[:])
testing.expect(t, dec2_ok, "second decryption should succeed")
if !dec2_ok {
return
}
defer delete(plaintext2)
testing.expect_value(t, len(plaintext2), len(original_data))
os.remove(data_path)
os.remove(envr_dir_path)
home := filepath.dir(filepath.dir(envr_dir_path))
os.remove(home)
}
@(test)
test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) {
key := fixture_key()
priv_kp, priv_ok := parse_ssh_private_key(key.private)
testing.expect(t, priv_ok, "should parse private key from fixtures")
if !priv_ok {
return
}
pub_key, pub_ok := parse_ssh_public_key(key.public)
testing.expect(t, pub_ok, "should parse public key from fixtures")
if !pub_ok {
return
}
for i in 0 ..< 32 {
testing.expectf(t, priv_kp.Public[i] == pub_key[i], "public key mismatch at byte %d", i)
}
x25519_pairs, x_ok := ssh_to_x25519([]SshKeyPair{key})
testing.expect(t, x_ok, "ssh_to_x25519 should succeed")
if !x_ok {
return
}
testing.expect_value(t, len(x25519_pairs), 1)
}
@(test)
test_config_load_with_fixture_key :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.keys)
}
testing.expect_value(t, len(cfg.keys), 1)
key := cfg.keys[0]
testing.expectf(t, len(key.private) > 0, "private key path should not be empty")
testing.expectf(t, len(key.public) > 0, "public key path should not be empty")
_, priv_ok := parse_ssh_private_key(key.private)
testing.expect(t, priv_ok, "should parse private key using config paths")
if !priv_ok {
fmt.printf(" private key path was: '%s'\n", key.private)
}
}
+24 -562
View File
@@ -1,217 +1,46 @@
#+test
package main package main
import "core:crypto/hash"
import "core:encoding/hex"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:sys/posix"
import "core:testing" import "core:testing"
import "sqlite" @(test)
test_db_update_required_noop :: proc(t: ^testing.T) {
make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {}) -> EnvFile { testing.expect(t, !db_update_required(.Noop), "Noop should not require update")
f := EnvFile {
path = path,
dir = "",
sha256 = sha,
contents = contents,
remotes = make([dynamic]string, 0, len(remotes), context.temp_allocator),
}
for r in remotes {
append(&f.remotes, r)
}
return f
} }
@(test) @(test)
test_db_insert_and_fetch :: proc(t: ^testing.T) { test_db_update_required_backed_up :: proc(t: ^testing.T) {
db, ok := db_init() testing.expect(t, db_update_required(.BackedUp), "BackedUp should require update")
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&db)
path := "/project/.env"
sha := "abc123"
contents := "SECRET=value"
f := make_test_env_file(path, sha, contents, []string{"git@github.com:user/repo.git"})
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
fetched, fetch_ok := db_fetch(&db, "/project/.env")
// defer delete_envfile(&fetched)
testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return
testing.expect_value(t, fetched.path, path)
testing.expect_value(t, fetched.sha256, sha)
testing.expect_value(t, fetched.contents, contents)
testing.expect_value(t, len(fetched.remotes), 1)
testing.expect_value(t, fetched.remotes[0], "git@github.com:user/repo.git")
} }
@(test) @(test)
test_db_fetch_missing :: proc(t: ^testing.T) { test_db_update_required_dir_updated :: proc(t: ^testing.T) {
db, ok := db_init() testing.expect(t, db_update_required(.DirUpdated), "DirUpdated should require update")
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&db)
_, fetch_ok := db_fetch(&db, "/nonexistent/.env")
testing.expect(t, !fetch_ok, "fetch missing should return false")
} }
@(test) @(test)
test_db_insert_or_replace :: proc(t: ^testing.T) { test_db_update_required_restored :: proc(t: ^testing.T) {
db, ok := db_init() testing.expect(t, !db_update_required(.Restored), "Restored alone should not require update")
defer db_close(&db)
testing.expect(t, ok, "failed to create test db")
f1 := make_test_env_file("/project/.env", "sha1", "KEY=old")
defer delete(f1.remotes)
testing.expect(t, db_insert(&db, f1), "first insert should succeed")
f2 := make_test_env_file("/project/.env", "sha2", "KEY=new")
defer delete(f2.remotes)
testing.expect(t, db_insert(&db, f2), "second insert should succeed")
results, list_ok := db_list(&db)
testing.expect(t, list_ok, "list should succeed")
testing.expect_value(t, len(results), 1)
fetched, fetch_ok := db_fetch(&db, "/project/.env")
testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return
// defer delete_envfile(&fetched)
testing.expect_value(t, fetched.contents, "KEY=new")
testing.expect_value(t, fetched.sha256, "sha2")
} }
@(test) @(test)
test_db_delete_existing :: proc(t: ^testing.T) { test_db_update_required_error :: proc(t: ^testing.T) {
db, ok := db_init() testing.expect(t, !db_update_required(.Error), "Error alone should not require update")
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.remotes)
db_insert(&db, f)
testing.expect(t, db_delete(&db, "/project/.env"), "delete should return true")
_, fetch_ok := db_fetch(&db, "/project/.env")
testing.expect(t, !fetch_ok, "row should be gone after delete")
} }
@(test) @(test)
test_db_delete_missing :: proc(t: ^testing.T) { test_db_update_required_combined :: proc(t: ^testing.T) {
db, ok := db_init() s := i32(SyncResult.DirUpdated) | i32(SyncResult.Restored)
testing.expect(t, ok, "failed to create test db") combined := SyncResult(s)
if !ok do return testing.expect(t, db_update_required(combined), "DirUpdated|Restored should require update")
defer db_close(&db)
testing.expect(t, !db_delete(&db, "/nonexistent/.env"), "delete missing should return false")
}
@(test)
test_db_list_multiple :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"})
defer delete(f1.remotes)
f2 := make_test_env_file("/proj2/.env", "sha2", "B=2", []string{"git@github.com:b/repo.git"})
defer delete(f2.remotes)
f3 := make_test_env_file("/proj3/.env", "sha3", "C=3")
db_insert(&db, f1)
db_insert(&db, f2)
db_insert(&db, f3)
results, list_ok := db_list(&db)
testing.expect(t, list_ok, "list should succeed")
testing.expect_value(t, len(results), 3)
}
@(test)
test_db_list_empty :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
results, list_ok := db_list(&db)
testing.expect(t, list_ok, "list should succeed on empty db")
testing.expect_value(t, len(results), 0)
}
@(test)
test_db_insert_sets_changed :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&db)
testing.expect(t, !db.changed, "changed should start false")
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.remotes)
db_insert(&db, f)
testing.expect(t, db.changed, "changed should be true after insert")
}
@(test)
test_db_delete_sets_changed :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.remotes)
db_insert(&db, f)
db.changed = false
db_delete(&db, "/project/.env")
testing.expect(t, db.changed, "changed should be true after delete")
}
@(test)
test_db_serialize :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer db_close(&db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.remotes)
db_insert(&db, f)
sz: i64
data := sqlite.serialize(db.conn, "main", &sz, {})
testing.expect(t, data != nil, "serialize should return non-nil")
if data == nil do return
defer sqlite.free(data)
testing.expect(t, sz > 0, "serialized size should be > 0")
} }
@(test) @(test)
test_shares_remote_overlap :: proc(t: ^testing.T) { test_shares_remote_overlap :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
remotes = make([dynamic]string, 2, context.temp_allocator), Remotes = make([dynamic]string, 2, context.temp_allocator),
} }
append(&f.remotes, "git@github.com:user/repo.git") append(&f.Remotes, "git@github.com:user/repo.git")
append(&f.remotes, "git@gitlab.com:user/repo.git") append(&f.Remotes, "git@gitlab.com:user/repo.git")
remotes := []string{"git@github.com:user/repo.git"} remotes := []string{"git@github.com:user/repo.git"}
testing.expect(t, shares_remote(&f, remotes), "should share remote") testing.expect(t, shares_remote(&f, remotes), "should share remote")
@@ -220,9 +49,9 @@ test_shares_remote_overlap :: proc(t: ^testing.T) {
@(test) @(test)
test_shares_remote_no_overlap :: proc(t: ^testing.T) { test_shares_remote_no_overlap :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
remotes = make([dynamic]string, 1, context.temp_allocator), Remotes = make([dynamic]string, 1, context.temp_allocator),
} }
append(&f.remotes, "git@github.com:user/repo.git") append(&f.Remotes, "git@github.com:user/repo.git")
remotes := []string{"git@github.com:other/repo.git"} remotes := []string{"git@github.com:other/repo.git"}
testing.expect(t, !shares_remote(&f, remotes), "should not share remote") testing.expect(t, !shares_remote(&f, remotes), "should not share remote")
@@ -231,7 +60,7 @@ test_shares_remote_no_overlap :: proc(t: ^testing.T) {
@(test) @(test)
test_shares_remote_empty_file_remotes :: proc(t: ^testing.T) { test_shares_remote_empty_file_remotes :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
remotes = make([dynamic]string, 0, context.temp_allocator), Remotes = make([dynamic]string, 0, context.temp_allocator),
} }
remotes := []string{"git@github.com:user/repo.git"} remotes := []string{"git@github.com:user/repo.git"}
@@ -241,9 +70,9 @@ test_shares_remote_empty_file_remotes :: proc(t: ^testing.T) {
@(test) @(test)
test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) { test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
remotes = make([dynamic]string, 1, context.temp_allocator), Remotes = make([dynamic]string, 1, context.temp_allocator),
} }
append(&f.remotes, "git@github.com:user/repo.git") append(&f.Remotes, "git@github.com:user/repo.git")
remotes: []string remotes: []string
testing.expect(t, !shares_remote(&f, remotes), "empty check remotes should not share") testing.expect(t, !shares_remote(&f, remotes), "empty check remotes should not share")
@@ -252,377 +81,10 @@ test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) {
@(test) @(test)
test_shares_remote_both_empty :: proc(t: ^testing.T) { test_shares_remote_both_empty :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
remotes = make([dynamic]string, 0), Remotes = make([dynamic]string, 0),
} }
remotes: []string remotes: []string
testing.expect(t, !shares_remote(&f, remotes), "both empty should not share") testing.expect(t, !shares_remote(&f, remotes), "both empty should not share")
} }
delete_remotes :: proc(remotes: [dynamic]string) {
for &r in remotes {
delete(r)
}
delete(remotes)
}
@(test)
test_get_git_remotes_single :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-*")
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 1)
if len(remotes) != 1 do return
testing.expect_value(t, remotes[0], "git@github.com:user/repo.git")
}
@(test)
test_get_git_remotes_multiple :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-multi-*")
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n[remote \"upstream\"]\n\turl = https://gitlab.com/upstream/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 2)
}
@(test)
test_get_git_remotes_no_config :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-none-*")
defer os.remove_all(base)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 0)
}
@(test)
test_get_git_remotes_no_remotes :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-empty-*")
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[core]\n\trepositoryformatversion = 0\n\tbare = false\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 0)
}
@(test)
test_new_env_file :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-envfile-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "SECRET=value\n")
testing.expect_value(t, err, nil)
file, ok := new_env_file(env_path)
testing.expect(t, ok, "new_env_file should succeed")
if !ok do return
defer delete(file.contents)
defer delete(file.remotes)
defer delete(file.sha256)
defer delete(file.path)
testing.expect(t, filepath.is_abs(file.path), "path should be absolute")
testing.expect(t, strings.has_suffix(file.path, "/.env"), "path should end with /.env")
testing.expect_value(t, file.contents, "SECRET=value\n")
testing.expect_value(t, len(file.sha256), 64)
}
@(test)
test_new_env_file_missing :: proc(t: ^testing.T) {
_, ok := new_env_file("/tmp/envr-nonexistent-envfile/path/.env")
testing.expect(t, !ok, "missing file should return false")
}
@(test)
test_closing_db_has_no_leaks :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-leak-*")
defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect_value(t, err, nil)
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
}
db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open")
db_close(&db)
}
@(test)
test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-leak-existing-*")
defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect_value(t, err, nil)
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
}
// First open/close creates data.envr on disk
db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open")
if !ok do return
f := make_test_env_file(
"/project/.env",
"abc123",
"SECRET=value",
[]string{"git@github.com:user/repo.git"},
)
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
// Second open exercises db_restore_from_encrypted
db2, ok2 := db_open(cfg_path)
testing.expect(t, ok2, "db should open existing")
if !ok2 do return
db_close(&db2)
}
@(test)
test_db_sync_noop :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-noop-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
content := "KEY=value\n"
write_err := os.write_entire_file(env_path, transmute([]u8)content)
testing.expect_value(t, write_err, nil)
digest := hash.hash_bytes(
hash.Algorithm.SHA256,
transmute([]u8)content,
context.temp_allocator,
)
hex_bytes := hex.encode(digest, context.temp_allocator)
sha := string(hex_bytes)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file(env_path, sha, content)
f.dir = base
db_insert(&db, f)
result, sync_err := db_sync(&db, &f)
testing.expect_value(t, sync_err, SyncError.None)
testing.expect_value(t, result, nil)
}
@(test)
test_db_sync_backed_up :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-backup-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
changed_content := "KEY=changed\n"
write_err := os.write_entire_file(env_path, transmute([]u8)changed_content)
testing.expect_value(t, write_err, nil)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file(env_path, "old_sha", "KEY=original")
f.dir = base
db_insert(&db, f)
result, sync_err := db_sync(&db, &f)
testing.expect_value(t, sync_err, SyncError.None)
testing.expect(t, .BackedUp in result, "should be backed up")
}
@(test)
test_db_sync_restored :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-restore-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file(env_path, "some_sha", "SECRET=value")
f.dir = base
defer delete(f.remotes)
db_insert(&db, f)
result, err := db_sync(&db, &f)
testing.expect_value(t, err, SyncError.None)
testing.expect(t, .Restored in result, "should be restored")
data, read_err := os.read_entire_file_from_path(env_path, context.temp_allocator)
testing.expect_value(t, read_err, nil)
if read_err == nil {
testing.expect_value(t, string(data), "SECRET=value")
}
}
@(test)
test_db_sync_dir_missing :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file("/nonexistent/path/.env", "sha", "KEY=val")
db_insert(&db, f)
result, err := db_sync(&db, &f)
testing.expect_value(t, err, SyncError.DirMissing)
testing.expect_value(t, result, nil)
}
@(test)
test_db_sync_moved :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-moved-*")
search_root := fmt.tprintf("%s/search", base)
repo_dir := fmt.tprintf("%s/myproject", search_root)
git_dir := fmt.tprintf("%s/.git", repo_dir)
defer os.remove_all(base)
os.mkdir_all(git_dir)
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir)
write_err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, write_err, nil)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
db.cfg.scan_config.include = make([dynamic]string, 0, 1, context.temp_allocator)
append(&db.cfg.scan_config.include, search_root)
f := make_test_env_file(
"/old/nonexistent/path/.env",
"some_sha",
"SECRET=value",
[]string{"git@github.com:user/repo.git"},
)
testing.expect(t, db_insert(&db, f), "insert should succeed")
result, err := db_sync(&db, &f)
testing.expect_value(t, err, SyncError.None)
if err != .None do return
testing.expect(t, .DirUpdated in result, "should have DirUpdated flag")
testing.expect(t, .Restored in result, "should have Restored flag")
expected_path := fmt.tprintf("%s/.env", repo_dir)
testing.expect_value(t, f.path, expected_path)
testing.expect_value(t, f.dir, repo_dir)
_, old_exists := db_fetch(&db, "/old/nonexistent/path/.env")
testing.expect(t, !old_exists, "old path should be deleted from db")
new_fetched, new_ok := db_fetch(&db, expected_path)
testing.expect(t, new_ok, "new path should exist in db")
if new_ok {
testing.expect_value(t, new_fetched.contents, "SECRET=value")
}
}
@(test)
test_db_close_preserves_inodes :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-inode-*")
defer os.remove_all(base)
envr_dir, _ := filepath.join([]string{base, ".envr"}, context.temp_allocator)
config_path, _ := filepath.join([]string{envr_dir, "config.json"}, context.temp_allocator)
os.mkdir_all(envr_dir)
key_abs, _ := filepath.abs("fixtures/keys/insecure-test-key", context.temp_allocator)
cfg := new_config([]string{key_abs}, config_path)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "save_config should succeed")
// Create database
{
db, ok := db_open(config_path)
defer db_close(&db)
testing.expect(t, ok, "first db_open should succeed")
if !ok do return
env_path, _ := filepath.join([]string{base, ".env"}, context.temp_allocator)
f := make_test_env_file(env_path, "sha", "KEY=value")
db_insert(&db, f)
}
// Record inode of data.envr
data_file, _ := data_path(config_path, context.temp_allocator)
cdata := strings.clone_to_cstring(data_file)
defer delete(cdata)
sb1: posix.stat_t
testing.expect(t, posix.stat(cdata, &sb1) == .OK, "stat should succeed")
inode_before := sb1.st_ino
// Create hardlink
link_path, _ := filepath.join([]string{envr_dir, "data.envr.link"}, context.temp_allocator)
clink := strings.clone_to_cstring(link_path)
defer delete(clink)
testing.expect(t, posix.link(cdata, clink) == .OK, "hardlink should succeed")
// Update database
{
db, ok2 := db_open(config_path)
defer db_close(&db)
testing.expect(t, ok2, "second db_open should succeed")
if !ok2 do return
env_path2, _ := filepath.join([]string{base, ".env2"}, context.temp_allocator)
f2 := make_test_env_file(env_path2, "sha2", "KEY2=value2")
db_insert(&db, f2)
}
// Verify inode preserved
sb2: posix.stat_t
testing.expect(t, posix.stat(cdata, &sb2) == .OK, "stat should succeed after rewrite")
testing.expect(t, sb2.st_ino == inode_before, "data.envr inode should be unchanged")
// Verify hardlink still valid
sb_link: posix.stat_t
testing.expect(t, posix.stat(clink, &sb_link) == .OK, "stat on hardlink should succeed")
testing.expect(t, sb_link.st_ino == inode_before, "hardlink inode should match")
testing.expect(t, sb_link.st_nlink == 2, "hardlink count should still be 2")
}
+13 -13
View File
@@ -4,11 +4,11 @@ Manage your .env files.
### Synopsis ### Synopsis
envr keeps your .env synced to a local, encrypted database. envr keeps your .env synced to a local, age encrypted database.
Is a safe and easy way to gather all your .env files in one place where they can 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. easily be backed by another tool such as restic or git.
All your data is stored in ~/.envr/data.envr All your data is stored in ~/data.age
Getting started is easy: Getting started is easy:
@@ -33,26 +33,26 @@ Select the files you want to back up from the interactive list.
5. If you lose a repository, after re-cloning the repo into the same path it was 5. If you lose a repository, after re-cloning the repo into the same path it was
at before, restore your backup with: at before, restore your backup with:
> envr restore ~/<path to repository>/.env > envr restore ~/&lt;path to repository&gt;/.env
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for envr
``` ```
### SEE ALSO ### SEE ALSO
* [envr backup](envr_backup.md) - Import a .env file into envr
* [envr check](envr_check.md) - check if files in the current directory are backed up
* [envr deps](envr_deps.md) - Check for missing binaries
* [envr edit-config](envr_edit-config.md) - Edit your config with your default editor
* [envr init](envr_init.md) - Set up envr * [envr 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 scan](envr_scan.md) - Find and select .env files for backup
* [envr sync](envr_sync.md) - Update or restore your env backups * [envr sync](envr_sync.md) - Update or restore your env backups
* [envr backup](envr_backup.md) - Import a .env file into envr
* [envr restore](envr_restore.md) - Restore a .env file from the database
* [envr list](envr_list.md) - View your tracked files
* [envr remove](envr_remove.md) - Remove a .env file from your database
* [envr check](envr_check.md) - Check if files are backed up
* [envr version](envr_version.md) - Show envr's version * [envr version](envr_version.md) - Show envr's version
* [envr edit-config](envr_edit-config.md) - Edit your config with your default editor
* [envr completion](envr_completion.md) - Generate shell completion scripts
* [envr uninstall](envr_uninstall.md) - Remove envr configuration and database
+2 -9
View File
@@ -6,20 +6,13 @@ Import a .env file into envr
envr backup <path> [flags] envr backup <path> [flags]
``` ```
**Arguments:**
* `path` — Path to .env file to backup
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for backup
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
``` ```
*Alias: `add`*
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+3 -8
View File
@@ -1,23 +1,18 @@
## envr check ## envr check
Check if files are backed up check if files in the current directory are backed up
``` ```
envr check [path] [flags] envr check [path] [flags]
``` ```
**Arguments:**
* `path` — Path to check (defaults to current directory) (optional)
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for check
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
-28
View File
@@ -1,28 +0,0 @@
## envr completion
Generate shell completion scripts
### Synopsis
Supported shells:
nushell
bash
```
envr completion <shell> [flags]
```
**Arguments:**
* `shell` — Shell to generate completions for
### Options
```
-h, --help show this documentation
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+24
View File
@@ -0,0 +1,24 @@
## envr deps
Check for missing binaries
### Synopsis
envr relies on external binaries for certain functionality.
The check command reports on which binaries are available and which are not.
```
envr deps [flags]
```
### Options
```
-h, --help help for deps
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+2 -3
View File
@@ -9,11 +9,10 @@ envr edit-config [flags]
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for edit-config
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+6 -5
View File
@@ -5,7 +5,9 @@ Set up envr
### Synopsis ### Synopsis
The init command generates your initial config and saves it to The init command generates your initial config and saves it to
~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which 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 encrypt your databse. **Make 100% sure** that you have **a remote copy** of this
key somewhere, otherwise your data could be lost forever. key somewhere, otherwise your data could be lost forever.
@@ -16,12 +18,11 @@ envr init [flags]
### Options ### Options
``` ```
-h, --help show this documentation -f, --force Overwrite an existing config
-c, --config-file config file (default "~/.envr/config.json") -h, --help help for init
--color Whether or not to colorize output (default 'auto')
-f, --force Overwrite existing config
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+2 -4
View File
@@ -9,12 +9,10 @@ envr list [flags]
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for list
-c, --config-file config file (default "~/.envr/config.json")
-o, --output the format of output data (default 'table')
--color Whether or not to colorize output (default 'auto')
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+23
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.
+3 -8
View File
@@ -3,21 +3,16 @@
Remove a .env file from your database Remove a .env file from your database
``` ```
envr remove <path> [flags] envr remove [flags]
``` ```
**Arguments:**
* `path` — Path to .env file to remove
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for remove
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+4 -9
View File
@@ -1,23 +1,18 @@
## envr restore ## envr restore
Restore a .env file from the database Install a .env file from the database into your file system
``` ```
envr restore <path> [flags] envr restore [flags]
``` ```
**Arguments:**
* `path` — Path to .env file to restore
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for restore
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+2 -3
View File
@@ -9,11 +9,10 @@ envr scan [flags]
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for scan
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+2 -4
View File
@@ -9,12 +9,10 @@ envr sync [flags]
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for sync
-c, --config-file config file (default "~/.envr/config.json")
-o, --output the format of output data (default 'table')
--color Whether or not to colorize output (default 'auto')
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
-24
View File
@@ -1,24 +0,0 @@
## envr uninstall
Remove envr configuration and database
### Synopsis
This will remove all files generated by envr from your system.
```
envr uninstall [flags]
```
### Options
```
-h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
-f, --force Overwrite existing config
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+3 -1
View File
@@ -9,9 +9,11 @@ envr version [flags]
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for version
-l, --long Show all version information
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+55
View File
@@ -0,0 +1,55 @@
package main
import "base:runtime"
import "core:mem"
import "core:os"
import "core:strings"
Feature :: enum {
Git,
Fd,
Age,
}
AvailableFeatures :: bit_set[Feature]
check_features :: proc() -> AvailableFeatures {
feats: AvailableFeatures
s: mem.Scratch
mem.scratch_init(&s, 4 * mem.DEFAULT_PAGE_SIZE)
defer mem.scratch_destroy(&s)
context.temp_allocator = mem.scratch_allocator(&s)
path_env := os.get_env("PATH", context.temp_allocator)
paths := strings.split(path_env, ":", context.temp_allocator)
if find_binary(paths, "git") != "" {
feats += {.Git}
}
if find_binary(paths, "fd") != "" {
feats += {.Fd}
}
if find_binary(paths, "age") != "" {
feats += {.Age}
}
return feats
}
find_binary :: proc(
paths: []string,
name: string,
allocator: runtime.Allocator = context.temp_allocator,
) -> string {
for p in paths {
candidate := strings.join({strings.trim_right(p, "/"), name}, "/", allocator)
_, err := os.stat(candidate, allocator)
if err == nil {
return candidate
}
}
return ""
}
+34
View File
@@ -0,0 +1,34 @@
package main
import "core:os"
import "core:strings"
import "core:testing"
@(test)
test_find_binary_exists :: proc(t: ^testing.T) {
path := os.get_env("PATH", context.temp_allocator)
paths := strings.split(path, ":", context.temp_allocator)
result := find_binary(paths, "sh")
testing.expect(t, result != "", "sh should be found on PATH")
}
@(test)
test_find_binary_not_exists :: proc(t: ^testing.T) {
old_path := os.get_env("PATH", context.temp_allocator)
defer {
if old_path != "" {
os.set_env("PATH", old_path)
}
}
os.set_env("PATH", "/tmp/envr-nope")
path := os.get_env("PATH", context.temp_allocator)
paths := strings.split(path, ":", context.temp_allocator)
result := find_binary(paths, "no_such_binary_xyz")
testing.expect(t, result == "", "nonexistent binary should not be found")
}
-299
View File
@@ -1,299 +0,0 @@
package findr
import "core:os"
import "core:sort"
import "core:strings"
import "core:sys/linux"
import "core:testing"
// ============================================================================
// Gitignored file emission tests (emit ONLY gitignored files, descend everywhere)
// ============================================================================
@(test)
test_basic_gitignored :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n")
create_file(env, "repo/.env")
create_file(env, "repo/secrets.env")
create_file(env, "repo/normal.txt")
assert_output(t, env, nil, {}, {"repo/.env", "repo/secrets.env"})
}
@(test)
test_non_repo_not_scanned :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_dir(env, "norepo")
create_file(env, "norepo/.gitignore", "*.env\n")
create_file(env, "norepo/.env")
assert_output_empty(t, env, nil, {})
}
@(test)
test_negation_pattern :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n!prod.env\n")
create_file(env, "repo/.env")
create_file(env, "repo/secrets.env")
create_file(env, "repo/prod.env")
assert_output(t, env, nil, {}, {"repo/.env", "repo/secrets.env"})
}
@(test)
test_multiple_repos :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo1")
create_file(env, "repo1/.gitignore", "*.env\n")
create_file(env, "repo1/a.env")
create_git_repo(env, "repo2")
create_file(env, "repo2/.gitignore", "*.key\n")
create_file(env, "repo2/secret.key")
assert_output(t, env, nil, {}, {"repo1/a.env", "repo2/secret.key"})
}
@(test)
test_nested_repos :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "parent")
create_file(env, "parent/.gitignore", "*.env\n")
create_file(env, "parent/top.env")
create_git_repo(env, "parent/child")
create_file(env, "parent/child/.gitignore", "*.key\n")
create_file(env, "parent/child/api.key")
assert_output(t, env, nil, {}, {"parent/top.env", "parent/child/api.key"})
}
@(test)
test_nested_gitignore_read :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n")
create_dir(env, "repo/sub")
create_file(env, "repo/sub/.gitignore", "*.txt\n")
create_file(env, "repo/sub/secret.txt")
create_file(env, "repo/sub/.env")
assert_output(t, env, nil, {}, {"repo/sub/secret.txt", "repo/sub/.env"})
}
@(test)
test_nested_gitignore_negation :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.log\n")
create_dir(env, "repo/sub")
create_file(env, "repo/sub/.gitignore", "!important.log\n")
create_file(env, "repo/sub/important.log")
create_file(env, "repo/sub/debug.log")
assert_output(t, env, nil, {}, {"repo/sub/debug.log"})
}
@(test)
test_multisegment_pattern :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "build/output.txt\n")
create_dir(env, "repo/build")
create_file(env, "repo/build/output.txt")
create_file(env, "repo/build/other.txt")
create_file(env, "repo/output.txt")
assert_output(t, env, nil, {}, {"repo/build/output.txt"})
}
@(test)
test_no_gitignore_file :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.env")
assert_output_empty(t, env, nil, {})
}
@(test)
test_empty_gitignore :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "\n\n# comment\n\n")
create_file(env, "repo/.env")
assert_output_empty(t, env, nil, {})
}
@(test)
test_multiple_search_dirs :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "dir1/repo")
create_file(env, "dir1/repo/.gitignore", "*.env\n")
create_file(env, "dir1/repo/a.env")
create_file(env, "dir1/repo/normal.txt")
create_git_repo(env, "dir2/repo")
create_file(env, "dir2/repo/.gitignore", "*.env\n")
create_file(env, "dir2/repo/b.env")
dir1 := join_path(env.temp_dir, "dir1")
defer delete(dir1)
dir2 := join_path(env.temp_dir, "dir2")
defer delete(dir2)
results := make([dynamic]string)
defer {
for r in results {delete(r)}
delete(results)
}
opts := WalkOptions{}
thread_count := os.get_processor_core_count()
walk({dir1, dir2}, &results, opts, thread_count)
testing.expect_value(t, len(results), 2)
actual := make([dynamic]string, 0, len(results))
for r in results {
stripped := r
if strings.has_prefix(stripped, env.temp_dir) {
stripped = stripped[len(env.temp_dir):]
if len(stripped) > 0 && stripped[0] == os.Path_Separator {
stripped = stripped[1:]
}
}
append(&actual, stripped)
}
defer delete(actual)
expected := []string{"dir1/repo/a.env", "dir2/repo/b.env"}
sort.quick_sort(actual[:])
sort.quick_sort(expected[:])
for i in 0 ..< len(expected) {
testing.expect_value(t, actual[i], expected[i])
}
}
// ============================================================================
// Ignored directory recursion tests
// ============================================================================
@(test)
test_ignored_dir_descended :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "secrets/\n")
create_dir(env, "repo/secrets")
create_file(env, "repo/secrets/.env")
create_file(env, "repo/secrets/api.key")
// Ignored dir's contents are emitted AND descended into
assert_output(t, env, nil, {}, {"repo/secrets/", "repo/secrets/.env", "repo/secrets/api.key"})
}
@(test)
test_nested_ignored_dir :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "build/\n")
create_dir(env, "repo/build")
create_dir(env, "repo/build/sub")
create_file(env, "repo/build/output.txt")
create_file(env, "repo/build/sub/deep.env")
assert_output(
t,
env,
nil,
{},
{"repo/build/", "repo/build/output.txt", "repo/build/sub/", "repo/build/sub/deep.env"},
)
}
// ============================================================================
// Filter tests (excludes, pattern)
// ============================================================================
@(test)
test_excludes_prune_dirs :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n")
create_file(env, "repo/.env")
create_dir(env, "repo/vendor")
create_file(env, "repo/vendor/lib.env")
assert_output(t, env, nil, {excludes = {"vendor"}}, {"repo/.env"})
}
@(test)
test_pattern_filters_results :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n*.key\n")
create_file(env, "repo/.env")
create_file(env, "repo/secrets.env")
create_file(env, "repo/master.key")
assert_output(t, env, nil, {pattern = "\\.env$"}, {"repo/.env", "repo/secrets.env"})
}
// ============================================================================
// Special file type tests
// ============================================================================
@(test)
test_fifo_emitted :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n*.fifo\n")
fifo_path := join_path(env.temp_dir, "repo/test.fifo")
defer delete(fifo_path)
cpath := strings.clone_to_cstring(fifo_path)
defer delete(cpath)
linux.mknod(cpath, linux.S_IFIFO | linux.Mode{.IRUSR, .IWUSR}, 0)
assert_output(t, env, nil, {pattern = "\\.fifo$"}, {"repo/test.fifo"})
}
-88
View File
@@ -1,88 +0,0 @@
package findr
import "core:strings"
Gitignore :: struct {
rules: [dynamic]Rule,
}
Rule :: struct {
pattern: GlobPattern,
negated: bool,
dir_only: bool,
}
Match :: enum {
None,
Ignored,
Unignored,
}
is_ignored :: proc(gi: ^Gitignore, path: string, is_dir: bool) -> bool {
return check_match(gi, path, is_dir) == .Ignored
}
check_match :: proc(gi: ^Gitignore, path: string, is_dir: bool) -> Match {
result := Match.None
for &rule in gi.rules {
if rule.dir_only && !is_dir do continue
if glob_match_compiled(&rule.pattern, path) {
result = rule.negated ? .Unignored : .Ignored
}
}
return result
}
parse :: proc(content: string) -> Gitignore {
gi: Gitignore
gi.rules = make([dynamic]Rule)
remaining := content
for {
line, ok := strings.split_lines_iterator(&remaining)
if !ok do break
s := strings.trim_space(line)
if len(s) == 0 do continue
if s[0] == '#' do continue
negated := false
if s[0] == '!' {
negated = true
s = s[1:]
}
if len(s) > 0 && s[0] == '\\' {
if len(s) > 1 && (s[1] == '#' || s[1] == '!') {
s = s[1:]
}
}
dir_only := false
if len(s) > 0 && s[len(s) - 1] == '/' {
dir_only = true
s = s[:len(s) - 1]
}
anchored := false
if len(s) > 0 && s[0] == '/' {
anchored = true
s = s[1:]
}
if len(s) == 0 do continue
gp := glob_compile(s, anchored)
append(&gi.rules, Rule{pattern = gp, negated = negated, dir_only = dir_only})
}
return gi
}
destroy :: proc(gi: ^Gitignore) {
for &rule in gi.rules {
glob_destroy(&rule.pattern)
}
delete(gi.rules)
}
-118
View File
@@ -1,118 +0,0 @@
package findr
import "core:testing"
@(test)
test_is_ignored_basic :: proc(t: ^testing.T) {
gi := parse("*.env\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, ".env", false), true)
testing.expect_value(t, is_ignored(&gi, "foo.env", false), true)
testing.expect_value(t, is_ignored(&gi, ".env.local", false), false)
testing.expect_value(t, is_ignored(&gi, "config.yaml", false), false)
}
@(test)
test_is_ignored_negation :: proc(t: ^testing.T) {
gi := parse("*.env\n!.env.production\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, ".env", false), true)
testing.expect_value(t, is_ignored(&gi, ".env.production", false), false)
}
@(test)
test_is_ignored_dir_only :: proc(t: ^testing.T) {
gi := parse("node_modules/\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "node_modules", true), true)
testing.expect_value(t, is_ignored(&gi, "node_modules", false), false)
}
@(test)
test_is_ignored_anchored :: proc(t: ^testing.T) {
gi := parse("/secret.key\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "secret.key", false), true)
}
@(test)
test_is_ignored_comments_skipped :: proc(t: ^testing.T) {
gi := parse("# this is a comment\n#another\n*.tmp\n")
defer destroy(&gi)
testing.expect_value(t, len(gi.rules), 1)
testing.expect_value(t, is_ignored(&gi, "file.tmp", false), true)
}
@(test)
test_is_ignored_blank_lines_skipped :: proc(t: ^testing.T) {
gi := parse("\n\n \n*.log\n\n")
defer destroy(&gi)
testing.expect_value(t, len(gi.rules), 1)
}
@(test)
test_is_ignored_last_match_wins :: proc(t: ^testing.T) {
gi := parse("*.env\n!*.env\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, ".env", false), false)
}
@(test)
test_is_ignored_no_rules :: proc(t: ^testing.T) {
gi := parse("")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "anything", false), false)
}
@(test)
test_is_ignored_env_pattern :: proc(t: ^testing.T) {
gi := parse(".env*\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, ".env", false), true)
testing.expect_value(t, is_ignored(&gi, ".env.local", false), true)
testing.expect_value(t, is_ignored(&gi, ".envrc", false), true)
}
@(test)
test_is_ignored_globstar :: proc(t: ^testing.T) {
gi := parse("**/cache\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "cache", false), true)
testing.expect_value(t, is_ignored(&gi, "foo/cache", false), true)
testing.expect_value(t, is_ignored(&gi, "foo/bar/cache", false), true)
}
@(test)
test_star_negation_subpath :: proc(t: ^testing.T) {
gi := parse("*\n!public/\n")
defer destroy(&gi)
// public dir itself is un-ignored
testing.expect_value(t, is_ignored(&gi, "public", true), false)
// children of public/ should still be ignored by *
testing.expect_value(t, is_ignored(&gi, "public/uuid-dir", true), true)
testing.expect_value(t, is_ignored(&gi, "public/uuid-dir/file.txt", false), true)
}
@(test)
test_is_ignored_hash_pattern :: proc(t: ^testing.T) {
gi := parse("\\#*\\#\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "#foo#", false), true)
testing.expect_value(t, is_ignored(&gi, "#test#", false), true)
testing.expect_value(t, is_ignored(&gi, "AUTHORS", false), false)
testing.expect_value(t, is_ignored(&gi, "build.zig", false), false)
testing.expect_value(t, is_ignored(&gi, "ChangeLog", false), false)
}
-203
View File
@@ -1,203 +0,0 @@
package findr
Range :: struct {
lo: u8,
hi: u8,
}
Class_Data :: struct {
negated: bool,
ranges: [dynamic]Range,
}
Token_Kind :: enum u8 { Char, Star, Globstar, Question, Class }
Token :: struct {
kind: Token_Kind,
byte: u8,
class_idx: u16,
}
GlobPattern :: struct {
tokens: [dynamic]Token,
classes: [dynamic]Class_Data,
anchored: bool,
}
glob_compile :: proc(pattern: string, anchored: bool) -> GlobPattern {
gp: GlobPattern
gp.tokens = make([dynamic]Token)
gp.classes = make([dynamic]Class_Data)
gp.anchored = anchored
i := 0
for i < len(pattern) {
c := pattern[i]
if c == '*' {
if i + 1 < len(pattern) && pattern[i + 1] == '*' {
prev_slash := i == 0 || pattern[i - 1] == '/'
at_end := i + 2 >= len(pattern)
next_slash := !at_end && pattern[i + 2] == '/'
if prev_slash && (next_slash || at_end) {
append(&gp.tokens, Token{kind = .Globstar})
if next_slash {
i += 3
} else {
i += 2
}
} else {
append(&gp.tokens, Token{kind = .Star})
i += 2
}
} else {
append(&gp.tokens, Token{kind = .Star})
i += 1
}
} else if c == '?' {
append(&gp.tokens, Token{kind = .Question})
i += 1
} else if c == '[' {
i += 1
negated := false
if i < len(pattern) && pattern[i] == '!' {
negated = true
i += 1
}
ranges := make([dynamic]Range)
if i < len(pattern) && pattern[i] == ']' {
append(&ranges, Range{lo = ']', hi = ']'})
i += 1
}
for i < len(pattern) && pattern[i] != ']' {
if i + 2 < len(pattern) && pattern[i + 1] == '-' && pattern[i + 2] != ']' {
append(&ranges, Range{lo = pattern[i], hi = pattern[i + 2]})
i += 3
} else {
append(&ranges, Range{lo = pattern[i], hi = pattern[i]})
i += 1
}
}
if i < len(pattern) {
i += 1
}
class_idx := u16(len(gp.classes))
append(&gp.classes, Class_Data{negated = negated, ranges = ranges})
append(&gp.tokens, Token{kind = .Class, class_idx = class_idx})
} else if c == '\\' {
i += 1
if i < len(pattern) {
append(&gp.tokens, Token{kind = .Char, byte = pattern[i]})
i += 1
}
} else {
append(&gp.tokens, Token{kind = .Char, byte = c})
i += 1
}
}
return gp
}
match_tokens :: proc(tokens: []Token, classes: []Class_Data, ti: int, path: string, pi: int) -> bool {
if ti >= len(tokens) {
return pi == len(path)
}
tok := tokens[ti]
switch tok.kind {
case .Char:
if pi < len(path) && path[pi] == tok.byte {
return match_tokens(tokens, classes, ti + 1, path, pi + 1)
}
return false
case .Question:
if pi < len(path) && path[pi] != '/' {
return match_tokens(tokens, classes, ti + 1, path, pi + 1)
}
return false
case .Star:
max_end := pi
for max_end < len(path) && path[max_end] != '/' {
max_end += 1
}
for end := max_end; end >= pi; end -= 1 {
if match_tokens(tokens, classes, ti + 1, path, end) {
return true
}
}
return false
case .Globstar:
if ti + 1 >= len(tokens) {
return true
}
if match_tokens(tokens, classes, ti + 1, path, pi) {
return true
}
for end := pi + 1; end <= len(path); end += 1 {
if path[end - 1] == '/' {
if match_tokens(tokens, classes, ti + 1, path, end) {
return true
}
}
}
return false
case .Class:
if pi >= len(path) {
return false
}
cd := classes[tok.class_idx]
ch := path[pi]
in_range := false
for r in cd.ranges {
if ch >= r.lo && ch <= r.hi {
in_range = true
break
}
}
if in_range != cd.negated {
return match_tokens(tokens, classes, ti + 1, path, pi + 1)
}
return false
}
return false
}
glob_match_compiled :: proc(gp: ^GlobPattern, path: string) -> bool {
tokens := gp.tokens[:]
classes := gp.classes[:]
if gp.anchored {
return match_tokens(tokens, classes, 0, path, 0)
}
if match_tokens(tokens, classes, 0, path, 0) {
return true
}
for i := 1; i < len(path); i += 1 {
if path[i - 1] == '/' {
if match_tokens(tokens, classes, 0, path, i) {
return true
}
}
}
return false
}
glob_destroy :: proc(gp: ^GlobPattern) {
for &cd in gp.classes {
delete(cd.ranges)
}
delete(gp.classes)
delete(gp.tokens)
}
-111
View File
@@ -1,111 +0,0 @@
package findr
import "core:testing"
glob_match :: proc(pattern: string, path: string, anchored: bool) -> bool {
gp := glob_compile(pattern, anchored)
result := glob_match_compiled(&gp, path)
glob_destroy(&gp)
return result
}
@(test)
test_glob_simple :: proc(t: ^testing.T) {
testing.expect(t, glob_match("foo", "foo", false))
testing.expect(t, glob_match("foo", "bar/foo", false))
testing.expect(t, !glob_match("foo", "foobar", false))
testing.expect(t, !glob_match("foo", "foo/bar", false))
}
@(test)
test_glob_anchored :: proc(t: ^testing.T) {
testing.expect(t, glob_match("foo", "foo", true))
testing.expect(t, !glob_match("foo", "bar/foo", true))
testing.expect(t, !glob_match("foo", "foobar", true))
}
@(test)
test_glob_star :: proc(t: ^testing.T) {
testing.expect(t, glob_match("*.log", "test.log", false))
testing.expect(t, glob_match("*.log", ".log", false))
testing.expect(t, !glob_match("*.log", "test.txt", false))
testing.expect(t, !glob_match("*.log", "dir/test", false))
}
@(test)
test_glob_question :: proc(t: ^testing.T) {
testing.expect(t, glob_match("?.log", "a.log", false))
testing.expect(t, !glob_match("?.log", "ab.log", false))
testing.expect(t, !glob_match("?.log", ".log", false))
}
@(test)
test_glob_char_class :: proc(t: ^testing.T) {
testing.expect(t, glob_match("[abc].log", "a.log", false))
testing.expect(t, glob_match("[abc].log", "b.log", false))
testing.expect(t, !glob_match("[abc].log", "d.log", false))
}
@(test)
test_glob_negated_class :: proc(t: ^testing.T) {
testing.expect(t, glob_match("[!abc].log", "d.log", false))
testing.expect(t, !glob_match("[!abc].log", "a.log", false))
}
@(test)
test_glob_dot_literal :: proc(t: ^testing.T) {
testing.expect(t, glob_match(".env", ".env", false))
testing.expect(t, glob_match(".env", "dir/.env", false))
testing.expect(t, !glob_match(".env", "env", false))
testing.expect(t, !glob_match(".env", "x.env", false))
}
@(test)
test_glob_globstar_prefix :: proc(t: ^testing.T) {
testing.expect(t, glob_match("**/foo", "foo", false))
testing.expect(t, glob_match("**/foo", "a/b/foo", false))
testing.expect(t, !glob_match("**/foo", "foobar", false))
testing.expect(t, !glob_match("**/foo", "a/foobar", false))
}
@(test)
test_glob_globstar_suffix :: proc(t: ^testing.T) {
testing.expect(t, glob_match("abc/**", "abc/x", false))
testing.expect(t, glob_match("abc/**", "abc/x/y", false))
testing.expect(t, !glob_match("abc/**", "abc", false))
testing.expect(t, !glob_match("abc/**", "abcd/x", false))
}
@(test)
test_glob_globstar_middle :: proc(t: ^testing.T) {
testing.expect(t, glob_match("foo/**/bar", "foo/bar", false))
testing.expect(t, glob_match("foo/**/bar", "foo/x/bar", false))
testing.expect(t, !glob_match("foo/**/bar", "foo/barx", false))
testing.expect(t, !glob_match("foo/**/bar", "foo/x/y/baz", false))
}
@(test)
test_glob_backslash_escape :: proc(t: ^testing.T) {
testing.expect(t, glob_match("\\!foo", "!foo", false))
testing.expect(t, !glob_match("\\!foo", "foo", false))
}
@(test)
test_glob_hash_literal :: proc(t: ^testing.T) {
testing.expect(t, glob_match("#foo", "#foo", false))
testing.expect(t, !glob_match("#foo", "foo", false))
}
@(test)
test_glob_hash_pattern :: proc(t: ^testing.T) {
testing.expect(t, glob_match("#*#", "#test#", false))
testing.expect(t, glob_match("#*#", "##", false))
testing.expect(t, !glob_match("#*#", "test", false))
testing.expect(t, !glob_match("#*#", "#test", false))
}
@(test)
test_glob_empty :: proc(t: ^testing.T) {
testing.expect(t, glob_match("", "", false))
testing.expect(t, !glob_match("", "foo", false))
}
-136
View File
@@ -1,136 +0,0 @@
package findr
import "core:strings"
import "core:sync"
import "core:sys/linux"
import "core:thread"
RepoPool :: struct {
queue: [dynamic]string,
queue_mutex: sync.Mutex,
queue_sema: sync.Atomic_Sema,
results: ^[dynamic]string,
results_lock: sync.Mutex,
active: i64,
done: sync.One_Shot_Event,
threads: []^thread.Thread,
}
find_repos :: proc(
roots: []string,
results: ^[dynamic]string,
thread_count: int,
allocator := context.allocator,
) {
// TODO: This may be a code smell
context.allocator = allocator
if len(roots) == 0 do return
pool := new(RepoPool)
pool.queue = make([dynamic]string)
pool.results = results
pool.active = i64(len(roots))
pool.threads = make([]^thread.Thread, thread_count)
for root in roots {
root_clone := strings.clone(root)
append(&pool.queue, root_clone)
sync.atomic_sema_post(&pool.queue_sema)
}
for i in 0 ..< thread_count {
t := thread.create(repo_worker)
t.data = rawptr(pool)
t.init_context = context
thread.start(t)
pool.threads[i] = t
}
sync.one_shot_event_wait(&pool.done)
for _ in 0 ..< thread_count {
sync.atomic_sema_post(&pool.queue_sema)
}
for t in pool.threads {
thread.destroy(t)
}
delete(pool.threads)
for path in pool.queue {
delete(path)
}
delete(pool.queue)
free(pool)
}
repo_worker :: proc(t: ^thread.Thread) {
pool := cast(^RepoPool)t.data
for {
sync.atomic_sema_wait(&pool.queue_sema)
sync.mutex_lock(&pool.queue_mutex)
if len(pool.queue) == 0 {
sync.mutex_unlock(&pool.queue_mutex)
if sync.atomic_load_explicit(&pool.active, .Acquire) == 0 {
sync.one_shot_event_signal(&pool.done)
}
break
}
last := len(pool.queue) - 1
dir_path := pool.queue[last]
ordered_remove(&pool.queue, last)
sync.mutex_unlock(&pool.queue_mutex)
process_repo_dir(pool, dir_path)
delete(dir_path)
old := sync.atomic_sub_explicit(&pool.active, 1, .Release)
if old == 1 {
sync.one_shot_event_signal(&pool.done)
}
}
}
process_repo_dir :: proc(pool: ^RepoPool, dir_path: string) {
cpath := strings.clone_to_cstring(dir_path)
if cpath == nil do return
defer delete(cpath)
fd, open_err := linux.open(cpath, {.DIRECTORY, .CLOEXEC})
if open_err != .NONE do return
defer linux.close(fd)
if has_git_dir(fd) {
cloned := strings.clone(dir_path)
sync.mutex_lock(&pool.results_lock)
append(pool.results, cloned)
sync.mutex_unlock(&pool.results_lock)
}
buf: [32 * 1024]u8
for {
n, errno := linux.getdents(fd, buf[:])
if n <= 0 || errno != .NONE do break
offs := 0
for d in linux.dirent_iterate_buf(buf[:n], &offs) {
name := linux.dirent_name(d)
if name == "." || name == ".." do continue
if name == ".git" do continue
if d.type == .DIR {
child_path := join_path(dir_path, name)
sync.atomic_add_explicit(&pool.active, 1, .Relaxed)
sync.mutex_lock(&pool.queue_mutex)
append(&pool.queue, child_path)
sync.mutex_unlock(&pool.queue_mutex)
sync.atomic_sema_post(&pool.queue_sema)
}
}
}
}
-148
View File
@@ -1,148 +0,0 @@
package findr
import "core:fmt"
import "core:log"
import "core:os"
import "core:sort"
import "core:strings"
import "core:testing"
TestEnv :: struct {
temp_dir: string,
}
create_test_env :: proc() -> (env: TestEnv) {
tmp, err := os.mkdir_temp("", "findr-test-*", context.allocator)
if err != nil {
log.error("Failed to create temp dir:", err)
panic("Failed to create temp dir")
}
env.temp_dir = tmp
return
}
destroy_test_env :: proc(env: ^TestEnv) {
os.remove_all(env.temp_dir)
delete(env.temp_dir)
}
create_dir :: proc(env: TestEnv, path: string) {
full := join_path(env.temp_dir, path)
defer delete(full)
os.mkdir_all(full, os.Permissions_Default_Directory)
}
create_file :: proc(env: TestEnv, path: string, content: string = "") {
full := join_path(env.temp_dir, path)
defer delete(full)
dir_end := strings.last_index(full, os.Path_Separator_String)
if dir_end >= 0 {
dir_path := full[:dir_end]
os.mkdir_all(dir_path, os.Permissions_Default_Directory)
}
f, err := os.create(full)
if err != nil {
log.error("Failed to create file:", full, err)
return
}
if len(content) > 0 {
os.write_string(f, content)
}
os.close(f)
}
create_git_repo :: proc(env: TestEnv, path: string) {
sub := join_path(path, ".git")
defer delete(sub)
create_dir(env, sub)
}
assert_output :: proc(
t: ^testing.T,
env: TestEnv,
args: []string,
opts: WalkOptions,
expected: []string,
) {
results := collect_results(env, args, opts)
defer {
for r in results {delete(r)}
delete(results)
}
sorted_expected := make([dynamic]string, 0, len(expected))
for e in expected {append(&sorted_expected, e)}
defer delete(sorted_expected)
sorted_actual := make([dynamic]string, 0, len(results))
for a in results {append(&sorted_actual, a)}
defer delete(sorted_actual)
sort.quick_sort(sorted_expected[:])
sort.quick_sort(sorted_actual[:])
if len(sorted_expected) != len(sorted_actual) {
testing.fail(t)
log.error(
fmt.tprintf("Expected %d results, got %d", len(sorted_expected), len(sorted_actual)),
)
log.error("Expected:", sorted_expected[:])
log.error("Actual: ", sorted_actual[:])
return
}
for i in 0 ..< len(sorted_expected) {
if sorted_expected[i] != sorted_actual[i] {
testing.fail(t)
log.error(fmt.tprintf("Mismatch at index %d", i))
log.error("Expected:", sorted_expected[:])
log.error("Actual: ", sorted_actual[:])
return
}
}
}
assert_output_empty :: proc(t: ^testing.T, env: TestEnv, args: []string, opts: WalkOptions) {
results := collect_results(env, args, opts)
defer {
for r in results {delete(r)}
delete(results)
}
if len(results) > 0 {
testing.fail(t)
log.error(fmt.tprintf("Expected no results, got %d:", len(results)))
for r in results {
log.error(" ", r)
}
}
}
collect_results :: proc(env: TestEnv, args: []string, opts: WalkOptions) -> [dynamic]string {
results := make([dynamic]string)
full_args := make([dynamic]string, 0, len(args) + 1, context.temp_allocator)
append(&full_args, env.temp_dir)
for a in args {append(&full_args, a)}
thread_count := os.get_processor_core_count()
walk(full_args[:], &results, opts, thread_count)
for i in 0 ..< len(results) {
r := results[i]
if strings.has_prefix(r, env.temp_dir) {
stripped := r[len(env.temp_dir):]
if len(stripped) > 0 && stripped[0] == os.Path_Separator {
stripped = stripped[1:]
}
new_r := strings.clone(stripped)
delete(r)
results[i] = new_r
}
}
return results
}
-468
View File
@@ -1,468 +0,0 @@
package findr
import "core:bytes"
import "core:fmt"
import "core:os"
import "core:strings"
import "core:sync"
import "core:sync/chan"
import "core:sys/linux"
import "core:text/regex"
import "core:thread"
OUTPUT_BUF_SIZE :: 64 * 1024
WalkOptions :: struct {
pattern: string, // regex on basename; "" = match all
excludes: []string, // glob patterns to skip entirely
}
GIContext :: struct {
gi: ^Gitignore, // nil if this dir had no .gitignore
base_rel: string, // relative path from repo root to this dir
parent: ^GIContext, // parent context (nil if repo root)
}
WorkItem :: struct {
path: string, // absolute directory path
rel: string, // relative path from repo root ("" = root)
gi_ctx: ^GIContext, // gitignore chain (nil = outside any repo)
in_repo: bool, // true if inside a git repo
in_ignored: bool, // true if inside a gitignored directory
}
WalkerPool :: struct {
queue: [dynamic]WorkItem,
queue_mutex: sync.Mutex,
queue_sema: sync.Atomic_Sema,
result_chan: chan.Chan([]u8),
active: i64,
done: sync.One_Shot_Event,
threads: []^thread.Thread,
opts: WalkOptions,
pattern_re: regex.Regular_Expression,
has_pattern: bool,
exclude_gi: ^Gitignore,
all_contexts: [dynamic]^GIContext,
contexts_lock: sync.Mutex,
}
Collector_Data :: struct {
ch: chan.Chan([]u8),
results: ^[dynamic]string,
}
collect_worker :: proc(t: ^thread.Thread) {
data := cast(^Collector_Data)t.data
for {
batch := chan.recv(data.ch) or_break
defer delete(batch)
start := 0
for {
remaining: []u8
#no_bounds_check {remaining = batch[start:]}
idx := bytes.index_byte(remaining, '\n')
if idx < 0 do break
i := start + idx
if i > start {
segment: []u8
#no_bounds_check {segment = batch[start:i]}
s, _ := strings.clone(string(segment))
append(data.results, s)
}
start = i + 1
}
}
}
walk :: proc(
roots: []string,
results: ^[dynamic]string,
opts: WalkOptions,
thread_count: int,
allocator := context.allocator,
) {
// TODO: This may be a code smell
context.allocator = allocator
if len(roots) == 0 do return
ch, _ := chan.create(chan.Chan([]u8), max(2 * thread_count, 2), context.allocator)
defer chan.destroy(ch)
data := new(Collector_Data)
data.ch = ch
data.results = results
defer free(data)
collector := thread.create(collect_worker)
collector.data = rawptr(data)
collector.init_context = context
thread.start(collector)
pool := new(WalkerPool)
pool.queue = make([dynamic]WorkItem)
pool.result_chan = ch
pool.active = i64(len(roots))
pool.threads = make([]^thread.Thread, thread_count)
pool.all_contexts = make([dynamic]^GIContext)
pool.opts = opts
pool.exclude_gi = nil
pool.has_pattern = false
if len(opts.pattern) > 0 {
re, err := regex.create(opts.pattern, {regex.Flag.No_Capture})
if err == nil {
pool.pattern_re = re
pool.has_pattern = true
}
}
if len(opts.excludes) > 0 {
sb: strings.Builder
strings.builder_init(&sb)
for ex in opts.excludes {
fmt.sbprintf(&sb, "%s\n", ex)
}
content := strings.to_string(sb)
pool.exclude_gi = new(Gitignore)
pool.exclude_gi^ = parse(content)
strings.builder_destroy(&sb)
}
for root in roots {
root_clone, _ := strings.clone(root)
append(&pool.queue, WorkItem{path = root_clone})
sync.atomic_sema_post(&pool.queue_sema)
}
for i in 0 ..< thread_count {
t := thread.create(walk_worker)
t.data = rawptr(pool)
t.init_context = context
thread.start(t)
pool.threads[i] = t
}
sync.one_shot_event_wait(&pool.done)
for _ in 0 ..< thread_count {
sync.atomic_sema_post(&pool.queue_sema)
}
for t in pool.threads {
thread.destroy(t)
}
delete(pool.threads)
for item in pool.queue {
delete(item.path)
if len(item.rel) > 0 {delete(item.rel)}
}
delete(pool.queue)
for ctx in pool.all_contexts {
if ctx.gi != nil {
destroy(ctx.gi)
free(ctx.gi)
}
if len(ctx.base_rel) > 0 {
delete(ctx.base_rel)
}
free(ctx)
}
delete(pool.all_contexts)
if pool.has_pattern {
regex.destroy(pool.pattern_re)
}
if pool.exclude_gi != nil {
destroy(pool.exclude_gi)
free(pool.exclude_gi)
}
free(pool)
chan.close(ch)
thread.join(collector)
thread.destroy(collector)
}
flush_buf :: proc(ch: chan.Chan([]u8), local: ^[dynamic]u8) {
if len(local) == 0 do return
batch := local[:]
local^ = make([dynamic]u8, 0, OUTPUT_BUF_SIZE)
chan.send(ch, batch)
}
append_path :: proc(buf: ^[dynamic]u8, parent, name: string, trailing_slash: bool) {
need_sep := len(parent) > 0 && parent[len(parent) - 1] != os.Path_Separator
size := len(parent) + len(name) + 1
if need_sep do size += 1
if trailing_slash do size += 1
old_len := len(buf)
reserve(buf, old_len + size)
resize(buf, old_len + size)
pos := old_len
pos += copy(buf[pos:], parent)
if need_sep {buf[pos] = os.Path_Separator; pos += 1}
pos += copy(buf[pos:], name)
if trailing_slash {buf[pos] = os.Path_Separator; pos += 1}
buf[pos] = '\n'
}
walk_worker :: proc(t: ^thread.Thread) {
pool := cast(^WalkerPool)t.data
local_buf := make([dynamic]u8, 0, OUTPUT_BUF_SIZE)
defer {
if len(local_buf) > 0 {
flush_buf(pool.result_chan, &local_buf)
}
delete(local_buf)
}
for {
sync.atomic_sema_wait(&pool.queue_sema)
sync.mutex_lock(&pool.queue_mutex)
if len(pool.queue) == 0 {
sync.mutex_unlock(&pool.queue_mutex)
if sync.atomic_load_explicit(&pool.active, .Acquire) == 0 {
sync.one_shot_event_signal(&pool.done)
}
break
}
last := len(pool.queue) - 1
item := pool.queue[last]
ordered_remove(&pool.queue, last)
sync.mutex_unlock(&pool.queue_mutex)
process_dir(pool, item, &local_buf)
delete(item.path)
if len(item.rel) > 0 {delete(item.rel)}
if len(local_buf) >= OUTPUT_BUF_SIZE {
flush_buf(pool.result_chan, &local_buf)
}
old := sync.atomic_sub_explicit(&pool.active, 1, .Release)
if old == 1 {
sync.one_shot_event_signal(&pool.done)
}
}
}
process_dir :: proc(pool: ^WalkerPool, item: WorkItem, local_buf: ^[dynamic]u8) {
dir_path := item.path
cpath := strings.clone_to_cstring(dir_path)
if cpath == nil do return
defer delete(cpath)
fd, open_err := linux.open(cpath, {.DIRECTORY, .CLOEXEC})
if open_err != .NONE do return
defer linux.close(fd)
has_git := false
if !item.in_ignored {
has_git = has_git_dir(fd)
}
gi_ctx := item.gi_ctx
rel := item.rel
if has_git {
gi_ctx = nil
rel = ""
}
child_in_repo := has_git || item.in_repo
gi: ^Gitignore = nil
if !item.in_ignored {
gi = load_ignore_patterns(dir_path, child_in_repo)
}
if gi != nil {
new_ctx := new(GIContext)
new_ctx.gi = gi
if len(rel) > 0 {
new_ctx.base_rel, _ = strings.clone(rel)
}
new_ctx.parent = gi_ctx
sync.mutex_lock(&pool.contexts_lock)
append(&pool.all_contexts, new_ctx)
sync.mutex_unlock(&pool.contexts_lock)
gi_ctx = new_ctx
}
buf: [32 * 1024]u8
rel_buf: [4096]u8
for {
n, errno := linux.getdents(fd, buf[:])
if n <= 0 || errno != .NONE do break
offs := 0
for d in linux.dirent_iterate_buf(buf[:n], &offs) {
name := linux.dirent_name(d)
if name == "." || name == ".." do continue
if name == ".git" do continue
is_dir := d.type == .DIR
is_nondir := d.type != .DIR
if pool.exclude_gi != nil && is_ignored(pool.exclude_gi, name, is_dir) {
continue
}
entry_rel := build_rel(rel_buf[:], rel, name)
ignored := false
if item.in_ignored {
ignored = true
} else if gi_ctx != nil {
ignored = check_chain(gi_ctx, entry_rel, is_dir)
}
if is_dir {
if ignored && matches_pattern(pool, name) {
append_path(local_buf, dir_path, name, true)
}
child_rel, _ := strings.clone(entry_rel)
child_path := join_path(dir_path, name)
push_work(
pool,
WorkItem {
path = child_path,
rel = child_rel,
gi_ctx = gi_ctx,
in_repo = child_in_repo,
in_ignored = ignored,
},
)
} else if is_nondir {
if ignored && matches_pattern(pool, name) {
append_path(local_buf, dir_path, name, false)
}
}
}
}
}
check_chain :: proc(ctx: ^GIContext, entry_rel: string, is_dir: bool) -> bool {
c := ctx
for c != nil {
if c.gi != nil {
rel := relative_to(entry_rel, c.base_rel)
match := check_match(c.gi, rel, is_dir)
if match != .None {
return match == .Ignored
}
}
c = c.parent
}
return false
}
// TODO: Is this a copy of something in the core packages?
relative_to :: proc(entry_rel, base_rel: string) -> string {
if len(base_rel) == 0 do return entry_rel
prefix_len := len(base_rel)
if len(entry_rel) > prefix_len &&
entry_rel[prefix_len] == '/' &&
strings.has_prefix(entry_rel, base_rel) {
return entry_rel[prefix_len + 1:]
}
return entry_rel
}
build_rel :: proc(buf: []u8, rel, name: string) -> string {
if len(rel) == 0 do return name
pos := copy(buf, rel)
if pos < len(buf) {
buf[pos] = '/'
pos += 1
pos += copy(buf[pos:], name)
}
return string(buf[:pos])
}
matches_pattern :: proc(pool: ^WalkerPool, name: string) -> bool {
if !pool.has_pattern do return true
cap, ok := regex.match(pool.pattern_re, name)
regex.destroy(cap)
return ok
}
push_work :: proc(pool: ^WalkerPool, item: WorkItem) {
sync.atomic_add_explicit(&pool.active, 1, .Relaxed)
sync.mutex_lock(&pool.queue_mutex)
append(&pool.queue, item)
sync.mutex_unlock(&pool.queue_mutex)
sync.atomic_sema_post(&pool.queue_sema)
}
has_git_dir :: proc(fd: linux.Fd) -> bool {
git_fd, err := linux.openat(fd, ".git", {.DIRECTORY, .CLOEXEC})
if err == .NONE {
linux.close(git_fd)
return true
}
return false
}
load_ignore_patterns :: proc(dir_path: string, in_repo: bool) -> ^Gitignore {
has_patterns := false
sb: strings.Builder
strings.builder_init(&sb)
defer strings.builder_destroy(&sb)
if in_repo {
gi_path := join_path(dir_path, ".gitignore")
data, err := os.read_entire_file_from_path(gi_path, context.allocator)
delete(gi_path)
if err == .NONE {
fmt.sbprintf(&sb, "%s", string(data))
delete(data)
has_patterns = true
}
}
ig_path := join_path(dir_path, ".ignore")
idata, ierr := os.read_entire_file_from_path(ig_path, context.allocator)
delete(ig_path)
if ierr == .NONE {
fmt.sbprintf(&sb, "%s", string(idata))
delete(idata)
has_patterns = true
}
if !has_patterns do return nil
content := strings.to_string(sb)
gi := new(Gitignore)
gi^ = parse(content)
return gi
}
// TODO: Is this a copy of core package behavior?
join_path :: proc(parent, child: string) -> string {
need_sep := len(parent) == 0 || parent[len(parent) - 1] != os.Path_Separator
total := len(parent) + len(child)
if need_sep do total += 1
buf := make([]u8, total, context.allocator)
pos := copy(buf, parent)
if need_sep {
buf[pos] = os.Path_Separator
pos += 1
}
copy(buf[pos:], child)
return string(buf)
}
+21
View File
@@ -0,0 +1,21 @@
{
"db_path": "~/.envr/data.age",
"keys": [
{
"private": "~/.ssh/id_ed25519",
"public": "~/.ssh/id_ed25519.pub"
}
],
"scan": {
"matcher": "\\.env",
"exclude": [
"*\\.envrc",
"\\.local",
"node_modules",
"vendor"
],
"include": [
"~"
]
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
age-encryption.org/v1
-> ssh-ed25519 Boe0UQ 2ngx7jSJ8/yuAzTgeiiCTYZRSkBCeJfaHTL0u7k6ziU
0XmEy0bOTeW1MF9ev32n4xISPDl9UQNHzEB0vsZHDuU
--- UV7IjWFCCg79Pf3T9vUWBxT4MhgeARWp6E+LK9tMy1g
u‡No2Zÿꥡé–Ý…++˜‡°ð¾ÓYÏóíðy:æ@' xP¾
+1
View File
@@ -0,0 +1 @@
Hello, World!
-7
View File
@@ -1,7 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACC4CdhiPHmU44cyy9UZV1ISnDq9RbYl1m1qTYOXaSNougAAAIg+8A82PvAP
NgAAAAtzc2gtZWQyNTUxOQAAACC4CdhiPHmU44cyy9UZV1ISnDq9RbYl1m1qTYOXaSNoug
AAAEAalxEoCavixCImtND1I0YHZZjhOrBLxk//t9v0sjYNVLgJ2GI8eZTjhzLL1RlXUhKc
Or1FtiXWbWpNg5dpI2i6AAAABHRlc3QB
-----END OPENSSH PRIVATE KEY-----
-1
View File
@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILgJ2GI8eZTjhzLL1RlXUhKcOr1FtiXWbWpNg5dpI2i6 test
-8
View File
@@ -1,8 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABD342Kol/
iE3kW3alqJTPVpAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIF29NuS3O0JUKCj4
j/NmmJJyJk6n/MwI37WtVeWAC5c/AAAAoPFp0zRQufp8S+f68atSqFT1FYMUvGqL2cmmtJ
r+kXEeEvSGdi3xAxCSLuoe0tMeUYP8aUP1M5L9VzTpFoi8jBIfcPl/ZRX8F/+J4dhp5jno
3nQuo1AN0D60r+UmmX+Z0IzIrD2jIpZ/Y7P2kXT8OErIhtC4ZJs3nIIOKFY7ZzlM1IqbYH
dSSlpUnsAoMPjMb0eD0Q6s6JaldfiNshckauU=
-----END OPENSSH PRIVATE KEY-----
-1
View File
@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF29NuS3O0JUKCj4j/NmmJJyJk6n/MwI37WtVeWAC5c/ encrypted test key
-7
View File
@@ -1,7 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACCZhSOlxHj1zxd+P7adxHOjo3tqqe68AVQ1itJ96nJ95wAAAIh6gz6PeoM+
jwAAAAtzc2gtZWQyNTUxOQAAACCZhSOlxHj1zxd+P7adxHOjo3tqqe68AVQ1itJ96nJ95w
AAAEAEsVzs6egkWMZolD/pZCX5ZcZVXfd5wZ6Ja12f+PxAQJmFI6XEePXPF34/tp3Ec6Oj
e2qp7rwBVDWK0n3qcn3nAAAABXRlc3Qy
-----END OPENSSH PRIVATE KEY-----
-1
View File
@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmFI6XEePXPF34/tp3Ec6Oje2qp7rwBVDWK0n3qcn3n test2
-27
View File
@@ -1,27 +0,0 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
NhAAAAAwEAAQAAAQEAjwq/ISeK/TmKiV1NABIq+tFwevArpTRTyZ9eC5JyGvDzDB03buVl
6bXd6+cwv+h0AZa7BZN60ayv8zAUmyGpSxFN2gMFiJ/0iFYpTHiLZD4VUH8mCPllIehOdr
epchmlh14BeShJjlGzwBAlgiEON5V62gCWWLmkIzcAgUd3R2NUQfajl74wA0JBkaNeFwUp
nUARyPUeMVX8ZVUvbpE/WOFTZYfFZDkul6aSkAzEeyZq9s4qJ2mWt5acuXcMcUl6YtuAGM
Xii+uV1nJyQpNgHRdEZ2Ch1zmtiTrqjutdBUOfyQZJ3Ln9h/nPJDerUHZboyhu654dLbac
0P3pYciW8wAAA8BvZFJ5b2RSeQAAAAdzc2gtcnNhAAABAQCPCr8hJ4r9OYqJXU0AEir60X
B68CulNFPJn14LknIa8PMMHTdu5WXptd3r5zC/6HQBlrsFk3rRrK/zMBSbIalLEU3aAwWI
n/SIVilMeItkPhVQfyYI+WUh6E52t6lyGaWHXgF5KEmOUbPAECWCIQ43lXraAJZYuaQjNw
CBR3dHY1RB9qOXvjADQkGRo14XBSmdQBHI9R4xVfxlVS9ukT9Y4VNlh8VkOS6XppKQDMR7
Jmr2zionaZa3lpy5dwxxSXpi24AYxeKL65XWcnJCk2AdF0RnYKHXOa2JOuqO610FQ5/JBk
ncuf2H+c8kN6tQdlujKG7rnh0ttpzQ/elhyJbzAAAAAwEAAQAAAQAVAR96x1s1/vaUYDJ3
4bMU/J83NkA6dJofH7tIGLuPsDUIYNvseVwDOxT42IyEiaZLO26ADZ1535FAtR05gHJjFw
nnCw2Ld+2I/Zn35DWXxTQNC3ay16hdl8a50RNdMV3oqEmwGFXgw6eQ+u3/E0qKp/UPwQlS
wwPStfdphGyD+15BxNcc/ZTAByKe9JMi7KkygE02jUn9OMPjJJT9RR+oRXZHLq+yU8Fayl
QUDgmU5Vq8Mhp0P4JrmCMVeZuRhMPrk3XaDJFPgfSMY1fKEapW6itwsG9VTh6xUMxks26t
hk/GuGNjhmt5NOKpQDLLOTKd22u+PZ6kJJQcJjsj47ktAAAAgGcWjHLNm6T0Dp1p5hgfPy
QK019Xp24V1zlejyC0iykzBaC+ZFFS9JOBkqfdrrEE1nAzLvJblhUeWpmLBaqOF+PpPxkF
oAGXzYck2axVcXhpvgB71uOARGZntVDoxVoOC7vT6I2h8eL75pZNGYJZt1K9Zufr4UwNR4
F+FY194pSLAAAAgQDEx1MSFuVZ5sfAH7RteSHWjvyD/CWwbhVzL3IWeUXCMsf9HwUZZd8e
zgyqE6Dh65GTXviuy8Tpb4gT4Gne/QblMHGvdbFMlXNOfzz9U5VkF0q1Y/D4rN0Sa7+nzR
lZx/LKM20egfypNeJWBQT5KzZ8gEOamL7Qyyk5YG2q5evWnwAAAIEAuhdRyPjXaCM2NyvO
dPxvbnpEJZDWRw6iVWtzPAXgwIiI6ngEUVXK2O8T8j0Ufssk3AVbVj1OH8/KJonyWUbedM
mDaFhs4Uvd9iuSZdpS7PbLqHYonurg3m6dz4TrtoWUQuBATdGuIGrtkN+Y83e6UqOGT7lY
Vqw7lPqhNUowAy0AAAAIdGVzdC1yc2EBAgM=
-----END OPENSSH PRIVATE KEY-----

Some files were not shown because too many files have changed in this diff Show More