3 Commits
Author SHA1 Message Date
Spencer Brower a4637fcf3b chore(main): release 1.1.0 2026-10-15 14:30:59 -04:00
spencer d4c4ed8468 feat: Added view command for opening files. 2026-10-15 14:25:53 -04:00
spencer e0e9c93577 feat: Added support for remote files (via ssh). 2026-10-15 14:25:53 -04:00
29 changed files with 996 additions and 48 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## [1.1.0](https://github.com/sbrow/envr/compare/v1.0.0...v1.1.0) (2026-10-15)
### Features
* Added `view` command for opening files. ([d4c4ed8](https://github.com/sbrow/envr/commit/d4c4ed8468ab975db1f22ff862fcb2eea475ae6e))
* Added support for remote files (via ssh). ([e0e9c93](https://github.com/sbrow/envr/commit/e0e9c93577250fe8d486cd0909bbe6909fb97c75))
## [1.0.0](https://github.com/sbrow/envr/compare/v0.4.0...v1.0.0) (2026-07-27)
+5
View File
@@ -14,6 +14,11 @@
8. Add docs for "building from source"
9. Add `envr show` command that uses pager (`less`) unless -no-pager is passed.
10. Run remote SSH sync operations in parallel.
11. Envr sync should require confirmation before pushing remote files.
## Double-check AI output
+19 -1
View File
@@ -25,6 +25,7 @@ Flags :: struct {
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"`,
stdout: bool `args:"short=p" usage:"Print file contents directly to stdout instead of using a pager"`,
}
Flag_Type :: enum {
@@ -33,6 +34,7 @@ Flag_Type :: enum {
Output,
Color,
Force,
Stdout,
}
Output_Format :: enum {
@@ -105,7 +107,7 @@ key somewhere, otherwise your data could be lost forever.`,
name = "restore",
usage = "envr restore <path>",
short = "Restore a .env file from the database",
flags = GLOBAL_FLAGS,
flags = GLOBAL_FLAGS + {.Force},
args = {
{name = "path", completion = "tracked-paths", desc = "Path to .env file to restore"},
},
@@ -114,6 +116,7 @@ key somewhere, otherwise your data could be lost forever.`,
name = "list",
usage = "envr list",
short = "View your tracked files",
long = "Table output is sorted by stored path and numbered from 1. Use 'envr view <number>' to view the contents of a backup.",
flags = GLOBAL_FLAGS + {.Output},
},
{
@@ -138,6 +141,21 @@ key somewhere, otherwise your data could be lost forever.`,
},
},
},
{
name = "view",
usage = "envr view [path]",
short = "View a backed-up .env file",
long = "If no path is provided, the user will be asked to select one from a list. You may also pass the number from the \"#\" column of `envr list` instead of the full path. If a partial path is passed, the closest match will be opened.",
flags = GLOBAL_FLAGS + {.Stdout},
args = {
{
name = "path",
completion = "tracked-paths",
optional = true,
desc = "Path or substring, or number of a backed-up file.",
},
},
},
{name = "version", usage = "envr version", short = "Show envr's version", flags = {.Help}},
{
name = "edit-config",
+26 -11
View File
@@ -7,22 +7,29 @@ import "core:path/filepath"
// 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) {
_check_path: string
input_path: string
if len(cmd.args) > 0 {
_check_path = cmd.args[0]
input_path = cmd.args[0]
} else {
cwd, cwd_err := os.get_working_directory(context.temp_allocator)
if cwd_err != nil {
fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false)
return
}
_check_path = cwd
input_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)
resolved_path, _, _, is_remote, path_resolved := normalize_remote_identity(input_path)
if is_remote && !path_resolved {
return
}
if !is_remote {
abs_path, abs_err := filepath.abs(input_path, context.temp_allocator)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return
}
resolved_path = abs_path
}
db, db_ok := db_open(cmd.flags.config_file)
if !db_ok {
@@ -30,20 +37,23 @@ cmd_check :: proc(cmd: ^Command) {
}
defer db_close(&db)
is_dir := os.is_directory(check_path)
is_dir := false
if !is_remote {
is_dir = os.is_directory(resolved_path)
}
// TODO: set a reasonable default
files_in_path := make([dynamic]string, context.temp_allocator)
if is_dir {
scanned, scan_ok := scan_path(check_path, db.cfg)
scanned, scan_ok := scan_path(resolved_path, db.cfg)
if !scan_ok {
fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false)
return
}
files_in_path = scanned
} else {
append(&files_in_path, check_path)
append(&files_in_path, resolved_path)
}
db_files, list_ok := db_list(&db)
@@ -56,6 +66,8 @@ cmd_check :: proc(cmd: ^Command) {
if len(not_backed) == 0 {
if len(files_in_path) == 0 {
fmt.wprintln(cmd.out, "No .env files found in the specified directory.", flush = false)
} else if is_remote {
fmt.wprintln(cmd.out, "✓ Remote file is backed up.", flush = false)
} else {
fmt.wprintln(
cmd.out,
@@ -73,7 +85,10 @@ cmd_check :: proc(cmd: ^Command) {
for file in not_backed {
fmt.wprintf(cmd.out, " %s\n", file, flush = false)
}
fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false)
if is_remote {
fmt.wprintf(cmd.out, "\nRun 'envr backup %s' to back up this remote file.\n", resolved_path, flush = false)
} else {
fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false)
}
}
}
+43
View File
@@ -2,6 +2,10 @@
package main
import "core:testing"
import "core:bufio"
import "core:os"
import "core:path/filepath"
import "core:strings"
@(test)
test_find_unbacked_finds_missing :: proc(t: ^testing.T) {
@@ -42,3 +46,42 @@ test_find_unbacked_none_backed :: proc(t: ^testing.T) {
testing.expect_value(t, len(result), 2)
}
@(test)
test_cmd_check_remote_path :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-check-remote-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join({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), "config should save")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
file := make_test_env_file("spencer@example.com:/etc/umami.env", "abc123", "SECRET=value")
testing.expect(t, db_insert(&db, file), "remote record should insert")
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", "check", "spencer@example.com:/etc/umami.env", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "command should parse")
if !ok do return
defer delete_command(&cmd)
cmd_check(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
error_output := strings.to_string(err_b)
testing.expect(t, strings.contains(output, "Remote file is backed up"), "tracked remote should report backed up")
testing.expect(t, len(error_output) == 0, "remote path should not be treated as a local path")
}
+3 -3
View File
@@ -32,18 +32,19 @@ cmd_list :: proc(cmd: ^Command) {
table.aligned_header_of_values(
&t,
.Center,
colorize(.Table_Heading, "#"),
colorize(.Table_Heading, "Directory"),
colorize(.Table_Heading, "Path"),
)
for row in rows {
for row, i in rows {
dir_str := strings.concatenate(
{row.dir, os.Path_Separator_String},
context.temp_allocator,
)
filename := filepath.base(row.path)
table.row(&t, dir_str, filename)
table.row(&t, fmt.tprintf("%d", i + 1), dir_str, filename)
}
table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width)
@@ -73,4 +74,3 @@ cmd_list :: proc(cmd: ^Command) {
fmt.wprintln(cmd.out, string(data), flush = false)
}
}
+20 -3
View File
@@ -80,9 +80,12 @@ test_cmd_list_output_table :: proc(t: ^testing.T) {
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")
paths := []string{"/zeta/.env", "/alpha/.env", "/middle/.env"}
for path in paths {
f := make_test_env_file(path, "abc123", "SECRET=value")
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
}
db_close(&db)
out_b: strings.Builder
@@ -106,6 +109,20 @@ test_cmd_list_output_table :: proc(t: ^testing.T) {
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "│"), "table output should contain border chars")
testing.expect(t, strings.contains(output, "#"), "table output should show a number header")
alpha := strings.index(output, "/alpha/")
middle := strings.index(output, "/middle/")
zeta := strings.index(output, "/zeta/")
testing.expect(
t,
alpha >= 0 && alpha < middle && middle < zeta,
"table should be sorted by full stored path",
)
testing.expect(
t,
strings.contains(output, "│ 1 │ /alpha/"),
"first path should be numbered 1",
)
testing.expect(
t,
strings.contains(output, "Directory"),
+12 -6
View File
@@ -16,11 +16,18 @@ cmd_remove :: proc(cmd: ^Command) {
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)
resolved_path, _, _, is_remote, path_resolved := normalize_remote_identity(path)
if is_remote && !path_resolved {
return
}
if !is_remote {
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
}
resolved_path = abs_path
}
db, db_ok := db_open(cmd.flags.config_file)
if !db_ok {
@@ -28,10 +35,9 @@ cmd_remove :: proc(cmd: ^Command) {
}
defer db_close(&db)
if !db_delete(&db, abs_path) {
if !db_delete(&db, resolved_path) {
return
}
fmt.wprintf(cmd.out, "Removed %s from the database\n", abs_path, flush = false)
fmt.wprintf(cmd.out, "Removed %s from the database\n", resolved_path, flush = false)
}
+18 -5
View File
@@ -16,12 +16,18 @@ cmd_restore :: proc(cmd: ^Command) {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
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)
resolved_path, host, remote_path, is_remote, path_ok := normalize_remote_identity(path)
if is_remote && !path_ok {
return
}
if !is_remote {
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
}
resolved_path = abs_path
}
db, db_ok := db_open(cmd.flags.config_file)
if !db_ok {
@@ -29,10 +35,17 @@ cmd_restore :: proc(cmd: ^Command) {
}
defer db_close(&db)
file, fetch_ok := db_fetch(&db, abs_path)
file, fetch_ok := db_fetch(&db, resolved_path)
if !fetch_ok {
return
}
if is_remote {
if !write_remote_file(host, remote_path, file.contents, file.sha256, cmd.flags.force) {
return
}
fmt.wprintf(cmd.out, "Restored %s\n", file.path, flush = false)
return
}
dir := filepath.dir(file.path)
if err := os.mkdir_all(dir); err != nil {
+157
View File
@@ -0,0 +1,157 @@
package main
import "core:fmt"
import "core:io"
import "core:os"
import "core:path/filepath"
import "core:strconv"
import "core:strings"
import "core:terminal"
cmd_view :: proc(cmd: ^Command) -> bool {
if len(cmd.args) > 1 {
print_command_help(cmd)
return false
}
path := len(cmd.args) == 1 ? cmd.args[0] : ""
db := db_open(cmd.flags.config_file) or_return
defer db_close(&db)
files := db_list(&db) or_return
paths := make([]string, len(files), context.temp_allocator)
for file, i in files do paths[i] = file.path
resolved_path := choose_view_path(path, paths, cmd.err) or_return
file := db_fetch(&db, resolved_path) or_return
if cmd.flags.stdout || !terminal.is_terminal(os.stdout) {
fmt.wprintf(cmd.out, "%s", file.contents, flush = false)
return true
}
page_file_contents(file.contents, cmd.err)
return true
}
choose_view_path :: proc(path: string, paths: []string, err_out: io.Writer) -> (string, bool) {
if len(strings.trim_space(path)) > 0 {
resolved, _, _, is_remote, path_ok := normalize_remote_identity(path)
if is_remote && !path_ok do return "", false
if !is_remote {
abs_path, abs_err := filepath.abs(path, context.temp_allocator)
if abs_err == nil do resolved = abs_path
}
for stored in paths {
if stored == resolved do return stored, true
}
if number, number_ok := strconv.parse_int(path, 10);
number_ok && number > 0 && number <= len(paths) {
return paths[number - 1], true
}
}
matches := matching_view_paths(path, paths)
if len(matches) == 0 {
fmt.wprintf(err_out, "No backed-up file matches: %s\n", path, flush = false)
return "", false
}
if len(path) > 0 && len(matches) == 1 {
return matches[0], true
}
if !terminal.is_terminal(os.stdin) || !terminal.is_terminal(os.stderr) {
fmt.wprintln(
err_out,
"An interactive terminal is required to choose a backup; pass a more specific path.",
flush = false,
)
return "", false
}
selected := single_select("Select a backed-up file:", matches[:])
if selected < 0 {
return "", false
}
return matches[selected], true
}
matching_view_paths :: proc(path: string, paths: []string) -> [dynamic]string {
matches := make([dynamic]string, 0, len(paths), context.temp_allocator)
needle := strings.to_lower(path, context.temp_allocator)
for stored in paths {
if strings.contains(strings.to_lower(stored, context.temp_allocator), needle) {
append(&matches, stored)
}
}
return matches
}
page_file_contents :: proc(contents: string, err_out: io.Writer) {
pager := os.get_env("PAGER", context.temp_allocator)
if len(strings.trim_space(pager)) == 0 {
when ODIN_OS == .Windows {
pager = "more"
} else {
pager = "less"
}
}
parts := strings.split(strings.trim_space(pager), " ", context.temp_allocator)
command := make([dynamic]string, 0, len(parts) + 1, context.temp_allocator)
for part in parts {
if len(part) > 0 {
append(&command, part)
}
}
if len(command) == 0 {
append(&command, "less")
}
when ODIN_OS == .Windows {
if command[0] != "more" do append(&command, "-")
} else {
append(&command, "-")
}
read_end, write_end, pipe_err := os.pipe()
if pipe_err != nil {
fmt.wprintf(err_out, "Error creating pager pipe: %v\n", pipe_err, flush = false)
return
}
defer os.close(write_end)
desc := os.Process_Desc {
command = command[:],
stdin = read_end,
stdout = os.stdout,
stderr = os.stderr,
}
process, start_err := os.process_start(desc)
os.close(read_end)
if start_err != nil {
fmt.wprintf(err_out, "Error starting pager %q: %v\n", command[0], start_err, flush = false)
return
}
remaining := transmute([]u8)contents
write_err: os.Error
for len(remaining) > 0 {
written, err := os.write(write_end, remaining)
if err != nil {
write_err = err
break
}
if written == 0 do break
remaining = remaining[written:]
}
os.close(write_end)
state, wait_err := os.process_wait(process)
if write_err != nil {
fmt.wprintf(err_out, "Error writing to pager: %v\n", write_err, flush = false)
}
if wait_err != nil {
fmt.wprintf(err_out, "Error waiting for pager: %v\n", wait_err, flush = false)
} else if state.exit_code != 0 {
fmt.wprintf(err_out, "Pager exited with status %d\n", state.exit_code, flush = false)
}
}
+158
View File
@@ -0,0 +1,158 @@
#+test
package main
import "core:bufio"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:terminal"
import "core:testing"
@(test)
test_cmd_view_writes_decrypted_backup_to_stdout :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-view-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join({base, "config.json"}, context.temp_allocator)
file_path, _ := filepath.join({base, ".env"}, context.temp_allocator)
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "config should save")
}
{
db, db_ok := db_open(cfg_path)
defer db_close(&db)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
file := make_test_env_file(file_path, "abc123", "SECRET=value\n")
testing.expect(t, db_insert(&db, file), "backup should insert")
}
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", "view", file_path, "--stdout", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "view command should parse")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, cmd_view(&cmd), "stored backup should be viewable without local file")
bufio.writer_flush(cmd.out_buf)
testing.expect_value(t, strings.to_string(out_b), "SECRET=value\n")
testing.expect_value(t, len(strings.to_string(err_b)), 0)
}
@(test)
test_cmd_view_number_follows_list_order :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-view-number-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join({base, "config.json"}, context.temp_allocator)
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "config should save")
}
{
db, db_ok := db_open(cfg_path)
defer db_close(&db)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
paths := []string{"/zeta/.env", "/alpha/.env"}
for path, i in paths {
contents := i == 0 ? "ZETA\n" : "ALPHA\n"
file := make_test_env_file(path, "abc123", contents)
testing.expect(t, db_insert(&db, file), "backup should insert")
delete(file.remotes)
}
}
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", "view", "1", "--stdout", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "view command should parse")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, cmd_view(&cmd), "first list position should resolve")
bufio.writer_flush(cmd.out_buf)
testing.expect_value(t, strings.to_string(out_b), "ALPHA\n")
}
@(test)
test_view_path_matches :: proc(t: ^testing.T) {
paths := []string{"/projects/api/.env", "/projects/web/.env", "user@host:/etc/service.env"}
matches := matching_view_paths("WEB", paths)
testing.expect_value(t, len(matches), 1)
if len(matches) > 0 do testing.expect_value(t, matches[0], paths[1])
remote := matching_view_paths("host:/etc", paths)
testing.expect_value(t, len(remote), 1)
if len(remote) > 0 do testing.expect_value(t, remote[0], paths[2])
}
@(test)
test_view_path_unique_and_missing :: proc(t: ^testing.T) {
paths := []string{"/projects/api/.env", "/projects/web/.env", "/projects/2"}
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
w := strings.to_writer(&err_b)
path, ok := choose_view_path("api", paths, w)
testing.expect(t, ok, "unique substring should resolve")
testing.expect_value(t, path, paths[0])
path, ok = choose_view_path("/projects/web/.env", paths, w)
testing.expect(t, ok, "exact stored path should resolve")
testing.expect_value(t, path, paths[1])
path, ok = choose_view_path("2", paths, w)
testing.expect(t, ok, "list position should resolve")
testing.expect_value(t, path, paths[1])
testing.expect(
t,
path != paths[2],
"list position should take priority over a partial path match",
)
path, ok = choose_view_path("3", paths, w)
testing.expect(t, ok, "third list position should resolve")
testing.expect_value(t, path, paths[2])
_, ok = choose_view_path("0", paths, w)
testing.expect(t, !ok, "zero is not a valid list position")
_, ok = choose_view_path("missing", paths, w)
testing.expect(t, !ok, "missing backup should fail")
testing.expect(
t,
strings.contains(strings.to_string(err_b), "No backed-up file matches"),
"missing backup should explain failure",
)
}
@(test)
test_view_picker_requires_terminal :: proc(t: ^testing.T) {
if terminal.is_terminal(os.stdin) && terminal.is_terminal(os.stderr) do return
paths := []string{"/projects/api/.env", "/projects/web/.env"}
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
_, no_path_ok := choose_view_path("", paths, strings.to_writer(&err_b))
testing.expect(t, !no_path_ok, "no path should require an interactive picker")
_, ambiguous_ok := choose_view_path("projects", paths, strings.to_writer(&err_b))
testing.expect(t, !ambiguous_ok, "ambiguous path should require an interactive picker")
}
+287 -3
View File
@@ -7,6 +7,7 @@ import "core:fmt"
import "core:mem"
import "core:os"
import "core:path/filepath"
import "core:path/slashpath"
import "core:strings"
import "sqlite"
@@ -19,6 +20,12 @@ SyncFlagEnum :: enum {
SyncFlag :: bit_set[SyncFlagEnum]
Remote_Read_Status :: enum {
Ok,
Missing,
Failed,
}
SyncError :: enum {
None,
DirMissing,
@@ -206,7 +213,7 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
stmt: sqlite.Stmt
rc := sqlite.prepare_v2(
db.conn,
"SELECT path, remotes, sha256, contents FROM envr_env_files",
"SELECT path, remotes, sha256, contents FROM envr_env_files ORDER BY path COLLATE BINARY",
-1,
&stmt,
nil,
@@ -316,7 +323,11 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
//
// Expects an absolute path
db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
assert(os.is_absolute_path(path))
if _, remote_path, is_remote := parse_remote_path(path); is_remote {
assert(slashpath.is_abs(remote_path))
} else {
assert(os.is_absolute_path(path))
}
sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?"
stmt: sqlite.Stmt
@@ -399,6 +410,17 @@ db_delete :: proc(db: ^Db, path: string) -> bool {
// Caller is responsible for the returned memory
new_env_file :: proc(path: string) -> (EnvFile, bool) {
if identity, host, remote_path, is_remote, resolved := normalize_remote_identity(path); is_remote {
if !resolved {
return EnvFile{}, false
}
contents, read_status := read_remote_file(host, remote_path)
if read_status != .Ok {
return EnvFile{}, false
}
return new_remote_env_file(identity, contents), true
}
abs_path, abs_err := filepath.abs(path)
if abs_err != nil {
fmt.eprintf("Error getting absolute path: %v\n", abs_err)
@@ -428,12 +450,66 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
true
}
// Takes ownership of contents and returns an EnvFile that owns all its fields.
new_remote_env_file :: proc(path: string, contents: []byte) -> EnvFile {
digest := hash.hash_bytes(hash.Algorithm.SHA256, contents, context.temp_allocator)
hex_bytes := hex.encode(digest, context.allocator)
return EnvFile {
path = strings.clone(path, context.allocator),
dir = "",
sha256 = string(hex_bytes),
contents = string(contents),
}
}
// Reconciles `f` with the filesystem and persists changes to the database.
db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
allocator := db_allocator(db)
result: SyncFlag = {}
old_path := f.path
if _, _, is_remote := parse_remote_path(f.path); is_remote {
identity, host, remote_path, _, resolved := normalize_remote_identity(f.path)
if !resolved {
return {}, .ReadFailed
}
contents, read_status := read_remote_file(host, remote_path)
if read_status == .Failed {
return {}, .ReadFailed
}
if read_status == .Missing {
if !write_remote_file(host, remote_path, f.contents, f.sha256, false) {
return {}, .WriteFailed
}
return {.Restored}, .None
}
digest := hash.hash_bytes(hash.Algorithm.SHA256, contents, context.temp_allocator)
hex_bytes := hex.encode(digest, allocator)
current_sha := string(hex_bytes)
if current_sha == f.sha256 {
delete(contents)
if identity != old_path {
f.path = strings.clone(identity, allocator)
if !db_persist(db, f, old_path) {
return {}, .DbFailed
}
}
return {}, .None
}
if identity != old_path {
f.path = strings.clone(identity, allocator)
}
f.contents = string(contents)
f.sha256 = current_sha
if !db_persist(db, f, old_path) {
return {}, .DbFailed
}
return {.BackedUp}, .None
}
if !os.exists(f.dir) {
moved, err := try_move_dir(db, f, allocator)
if !moved {
@@ -480,6 +556,215 @@ db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
return result + {.BackedUp}, .None
}
// A remote identity uses user@host:path; CLI input is expanded to an absolute path.
parse_remote_path :: proc(path: string) -> (host, remote_path: string, is_remote: bool) {
separator := strings.index(path, ":")
if separator <= 0 || separator + 1 >= len(path) {
return
}
host = path[:separator]
if strings.index(host, "@") <= 0 ||
strings.index(host, " ") >= 0 ||
strings.index(host, "'") >= 0 {
return "", "", false
}
remote_path = path[separator + 1:]
is_remote = true
return
}
// Resolves relative and tilde-prefixed remote input against the remote HOME.
// Absolute paths stay absolute, independent of the local host OS.
normalize_remote_identity :: proc(path: string) -> (
identity, host, remote_path: string,
is_remote, ok: bool,
) {
parsed_host, parsed_path, parsed_is_remote := parse_remote_path(path)
if !parsed_is_remote {
return path, "", "", false, true
}
host = parsed_host
is_remote = true
if slashpath.is_abs(parsed_path) {
remote_path = slashpath.clean(parsed_path, context.temp_allocator)
} else {
home, home_ok := remote_home_directory(host)
if !home_ok {
return "", host, "", true, false
}
remote_path = expand_remote_path(home, parsed_path)
}
identity = fmt.tprintf("%s:%s", host, remote_path)
ok = true
return
}
// Expands relative and tilde-prefixed remote input against an absolute POSIX home.
expand_remote_path :: proc(home, path: string) -> string {
if slashpath.is_abs(path) {
return slashpath.clean(path, context.temp_allocator)
}
relative := path
if strings.has_prefix(relative, "~") {
relative = relative[1:]
if strings.has_prefix(relative, "/") {
relative = relative[1:]
}
}
return slashpath.join({home, relative}, context.temp_allocator)
}
remote_home_directory :: proc(host: string) -> (home: string, ok: bool) {
// The command is constant; HOME is expanded by the remote shell and no
// user-supplied path is interpolated into it.
desc := os.Process_Desc {
command = []string{"ssh", "-T", "--", host, "printf '%s' \"$HOME\""},
}
state, stdout, stderr, err := os.process_exec(desc, context.temp_allocator)
delete(stderr)
if err != nil {
fmt.eprintf("Error querying remote home directory over ssh: %v\n", err)
delete(stdout)
return "", false
}
if !state.success {
fmt.eprintf("Error querying remote home directory over ssh (exit code %d)\n", state.exit_code)
delete(stdout)
return "", false
}
home = string(stdout)
if !slashpath.is_abs(home) {
fmt.eprintf("Error: remote HOME is not an absolute POSIX path\n")
return "", false
}
home = slashpath.clean(home, context.temp_allocator)
ok = true
return
}
// Read a remote file through OpenSSH. The path is quoted for the remote shell;
// the host and command are passed as distinct local argv elements.
read_remote_file :: proc(host, remote_path: string) -> (contents: []byte, status: Remote_Read_Status) {
quoted_path := quote_remote_shell_path(remote_path)
remote_command := fmt.tprintf("if [ -e '%s' ]; then cat -- '%s'; else exit 44; fi", quoted_path, quoted_path)
desc := os.Process_Desc {
command = []string{"ssh", "-T", "--", host, remote_command},
stdin = nil,
}
state, stdout, stderr, err := os.process_exec(desc, context.allocator)
if err != nil {
fmt.eprintf("Error starting ssh: %v\n", err)
delete(stdout)
delete(stderr)
return nil, .Failed
}
delete(stderr)
if !state.success {
if state.exit_code == 44 {
delete(stdout)
return nil, .Missing
}
fmt.eprintf("Error reading remote file via ssh (exit code %d)\n", state.exit_code)
delete(stdout)
return nil, .Failed
}
return stdout, .Ok
}
quote_remote_shell_path :: proc(path: string) -> string {
quoted_path, _ := strings.replace_all(path, "'", "'\"'\"'", context.temp_allocator)
return quoted_path
}
// Atomically writes a remote file via SSH stdin, never staging plaintext locally.
// Existing remote contents must match the stored digest unless force is set.
write_remote_file :: proc(host, remote_path, contents, expected_sha: string, force: bool) -> bool {
if len(expected_sha) != 64 {
fmt.eprintf("Error: invalid stored SHA-256 digest for remote restore\n")
return false
}
for c in expected_sha {
if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') && !(c >= 'A' && c <= 'F') {
fmt.eprintf("Error: invalid stored SHA-256 digest for remote restore\n")
return false
}
}
quoted_path := quote_remote_shell_path(remote_path)
force_check := force ? ":" : fmt.tprintf(`
if [ -e "$target" ]; then
if command -v sha256sum >/dev/null 2>&1; then
current=$(sha256sum -- "$target" | cut -d ' ' -f 1)
elif command -v shasum >/dev/null 2>&1; then
current=$(shasum -a 256 -- "$target" | cut -d ' ' -f 1)
else
echo 'Remote restore requires sha256sum or shasum; use --force to bypass conflict checking' >&2
exit 74
fi
if [ "$current" != '%s' ]; then
echo 'Remote file changed since backup; use --force to overwrite' >&2
exit 73
fi
fi
`, expected_sha)
remote_command := fmt.tprintf(`
set -eu
target='%s'
dir=${target%/*}
[ -n "$dir" ] || dir=/
mkdir -p -- "$dir"
umask 077
tmp=$(mktemp "$dir/.envr.XXXXXXXX")
trap 'rm -f -- "$tmp"' EXIT HUP INT TERM
cat > "$tmp"
%s
mv -f -- "$tmp" "$target"
trap - EXIT HUP INT TERM
`, quoted_path, force_check)
read_pipe, write_pipe, pipe_err := os.pipe()
if pipe_err != nil {
fmt.eprintf("Error creating SSH input pipe: %v\n", pipe_err)
return false
}
desc := os.Process_Desc {
command = []string{"ssh", "-T", "--", host, remote_command},
stdin = read_pipe,
}
process, start_err := os.process_start(desc)
_ = os.close(read_pipe)
if start_err != nil {
_ = os.close(write_pipe)
fmt.eprintf("Error starting ssh: %v\n", start_err)
return false
}
remaining := transmute([]byte)contents
write_err: os.Error
for len(remaining) > 0 {
written, err := os.write(write_pipe, remaining)
if err != nil {
write_err = err
break
}
if written == 0 {
break
}
remaining = remaining[written:]
}
_ = os.close(write_pipe)
state, wait_err := os.process_wait(process)
if write_err != nil {
fmt.eprintf("Error sending file contents over ssh: %v\n", write_err)
return false
}
if wait_err != nil || !state.success {
fmt.eprintf("Error writing remote file via ssh (exit code %d)\n", state.exit_code)
return false
}
return true
}
db_persist :: proc(db: ^Db, f: ^EnvFile, old_path: string) -> bool {
if f.path != old_path {
if !db_delete(db, old_path) {
@@ -591,4 +876,3 @@ clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string {
return str
}
+1
View File
@@ -51,6 +51,7 @@ at before, restore your backup with:
* [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 view](envr_view.md) - View a backed-up .env file
* [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
+1 -1
View File
@@ -6,7 +6,7 @@ Import a .env file into envr
envr backup <path> [flags]
```
**Arguments:**
### Arguments
* `path` — Path to .env file to backup
+1 -1
View File
@@ -6,7 +6,7 @@ Check if files are backed up
envr check [path] [flags]
```
**Arguments:**
### Arguments
* `path` — Path to check (defaults to current directory) (optional)
+1 -3
View File
@@ -2,8 +2,6 @@
Generate shell completion scripts
### Synopsis
Supported shells:
nushell
@@ -13,7 +11,7 @@ Supported shells:
envr completion <shell> [flags]
```
**Arguments:**
### Arguments
* `shell` — Shell to generate completions for
-2
View File
@@ -2,8 +2,6 @@
Set up envr
### Synopsis
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
encrypt your databse. **Make 100% sure** that you have **a remote copy** of this
+2
View File
@@ -2,6 +2,8 @@
View your tracked files
Table output is sorted by stored path and numbered from 1. Use 'envr view <number>' to view the contents of a backup.
```
envr list [flags]
```
+1 -1
View File
@@ -6,7 +6,7 @@ Remove a .env file from your database
envr remove <path> [flags]
```
**Arguments:**
### Arguments
* `path` — Path to .env file to remove
+6 -1
View File
@@ -6,16 +6,21 @@ Restore a .env file from the database
envr restore <path> [flags]
```
**Arguments:**
### Arguments
* `path` — Path to .env file to restore
Remote files are restored over SSH using an atomic replacement. If the remote
file has changed since it was backed up, restoration is refused unless
`--force` is specified.
### 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
+4
View File
@@ -2,6 +2,10 @@
Update or restore your env backups
For both local and remote files, `sync` backs up changed file contents and
restores missing files from the backup. Remote SSH and permission errors are
reported instead of being treated as missing files.
```
envr sync [flags]
```
-2
View File
@@ -2,8 +2,6 @@
Remove envr configuration and database
### Synopsis
This will remove all files generated by envr from your system.
```
+26
View File
@@ -0,0 +1,26 @@
## envr view
View a backed-up .env file
If no path is provided, the user will be asked to select one from a list. You may also pass the number from the "#" column of `envr list` instead of the full path. If a partial path is passed, the closest match will be opened.
```
envr view [path] [flags]
```
### Arguments
* `path` — Path or substring, or number of a backed-up file. (optional)
### Options
```
-h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
-p, --stdout Print file contents directly to stdout instead of using a pager
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+6
View File
@@ -72,10 +72,12 @@
nativeBuildInputs = [
pkgs.unstable.odin
pkgs.pkg-config
pkgs.makeWrapper
];
buildInputs = [
pkgs.git
pkgs.openssh
pkgs.libsodium
mysqlite
@@ -108,6 +110,10 @@
cp docs/man/*.1 $out/share/man/man1/
runHook postInstall
'';
postFixup = ''
wrapProgram $out/bin/envr --prefix PATH : ${nixpkgs.lib.makeBinPath [ pkgs.openssh ]}
'';
};
devShells.default = pkgs.mkShell {
+1 -3
View File
@@ -201,7 +201,6 @@ generate_md_command :: proc(c: ^CommandInfo) {
fmt.sbprintf(&sb, "%s\n\n", c.short)
if len(c.long) > 0 {
fmt.sbprintf(&sb, "### Synopsis\n\n")
fmt.sbprint(&sb, c.long)
fmt.sbprintf(&sb, "\n\n")
}
@@ -209,7 +208,7 @@ generate_md_command :: proc(c: ^CommandInfo) {
fmt.sbprintf(&sb, "```\n%s [flags]\n```\n\n", c.usage)
if len(c.args) > 0 {
fmt.sbprintf(&sb, "**Arguments:**\n\n")
fmt.sbprintf(&sb, "### Arguments\n\n")
for arg in c.args {
optional := arg.optional ? " (optional)" : ""
fmt.sbprintf(&sb, "* `%s` — %s%s\n", arg.name, arg.desc, optional)
@@ -286,4 +285,3 @@ write_file :: proc(path: string, content: string) {
fmt.eprintf("Error writing %s: %v\n", path, err)
}
}
+2
View File
@@ -74,6 +74,8 @@ when !#config(GENDOCS, false) {
cmd_edit_config(&cmd)
case "check":
cmd_check(&cmd)
case "view":
if !cmd_view(&cmd) do os.exit(1)
case "scan":
cmd_scan(&cmd)
case "sync":
+66 -1
View File
@@ -1,6 +1,7 @@
package main
import "core:fmt"
import "core:os"
import "core:strings"
import "core:sys/posix"
import "core:terminal/ansi"
@@ -28,6 +29,71 @@ Raw_State :: struct {
MAX_VISIBLE :: 7
// Returns the index of the chosen option, or -1 on cancellation or terminal error.
single_select :: proc(prompt: string, options: []string) -> int {
if len(options) == 0 do return -1
filter: [dynamic]u8
defer delete(filter)
filtered: [dynamic]int
defer delete(filtered)
rebuild_filtered(options, filter[:], &filtered)
raw, ok := enable_raw_mode(posix.STDIN_FILENO)
if !ok do return -1
defer disable_raw_mode(&raw)
cursor, scroll_offset := 0, 0
fmt.fprintf(os.stderr, ansi.CSI + ansi.DECTCEM_HIDE)
visible := render_single_options(prompt, options, filtered[:], "", cursor, scroll_offset)
defer fmt.fprintf(os.stderr, ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, visible + 1)
for {
key, ch := read_key()
switch key {
case .Char:
append(&filter, u8(ch))
rebuild_filtered(options, filter[:], &filtered)
cursor = clamp(cursor, 0, max(0, len(filtered) - 1))
case .Backspace:
if len(filter) > 0 {
pop(&filter)
rebuild_filtered(options, filter[:], &filtered)
cursor = clamp(cursor, 0, max(0, len(filtered) - 1))
}
case .Up:
if cursor > 0 do cursor -= 1
case .Down:
if cursor < len(filtered) - 1 do cursor += 1
case .Enter:
if len(filtered) > 0 do return filtered[cursor]
case .Escape:
return -1
case .Unknown:
return -1
case .Space:
}
scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(filtered) - MAX_VISIBLE))
fmt.fprintf(os.stderr, ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.RESET + ansi.ED, visible + 1)
visible = render_single_options(prompt, options, filtered[:], string(filter[:]), cursor, scroll_offset)
}
}
render_single_options :: proc(prompt: string, options: []string, filtered: []int, filter_text: string, cursor, scroll_offset: int) -> int {
fmt.fprintf(os.stderr, "%s (type to filter, ↑/↓ move, enter select, esc cancel)\r\n", prompt)
fmt.fprintf(os.stderr, "filter: %s\r\n", filter_text)
if len(filtered) == 0 {
fmt.fprintf(os.stderr, " No matches\r\n")
return 2
}
end := min(scroll_offset + MAX_VISIBLE, len(filtered))
for i in scroll_offset ..< end {
fmt.fprintf(os.stderr, "%s %s\r\n", i == cursor ? ">" : " ", options[filtered[i]])
}
return 1 + end - scroll_offset
}
// Caller is responsible for deleting the responses.
multi_select :: proc(
prompt: string,
@@ -263,4 +329,3 @@ read_key :: proc() -> (key: Key, ch: rune) {
return .Unknown, 0
}
}
+121
View File
@@ -0,0 +1,121 @@
#+test
package main
import "core:strings"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:testing"
@(test)
test_parse_remote_path :: proc(t: ^testing.T) {
host, path, ok := parse_remote_path("spencer@example.com:/srv/app/.env")
testing.expect(t, ok, "expected remote identity to be recognized")
testing.expect_value(t, host, "spencer@example.com")
testing.expect_value(t, path, "/srv/app/.env")
}
@(test)
test_parse_remote_path_rejects_local_paths :: proc(t: ^testing.T) {
_, _, ok := parse_remote_path("/srv/app/.env")
testing.expect(t, !ok, "local path should not be treated as remote")
_, _, ok = parse_remote_path("user@example.com:")
testing.expect(t, !ok, "remote path must not be empty")
}
@(test)
test_expand_remote_path_to_absolute_posix_path :: proc(t: ^testing.T) {
cases := [?][2]string{
{"outline/docker.env", "/root/outline/docker.env"},
{"~/outline/docker.env", "/root/outline/docker.env"},
{"~outline/docker.env", "/root/outline/docker.env"},
{"/root/outline/docker.env", "/root/outline/docker.env"},
{"../outside.env", "/outside.env"},
}
for c in cases {
got := expand_remote_path("/root", c[0])
testing.expect_value(t, got, c[1])
}
}
@(test)
test_normalize_remote_absolute_identity :: proc(t: ^testing.T) {
identity, host, path, is_remote, ok := normalize_remote_identity(
"root@vertaxdev.com:/root/outline/docker.env",
)
testing.expect(t, is_remote && ok, "absolute remote identity should normalize without SSH")
testing.expect_value(t, identity, "root@vertaxdev.com:/root/outline/docker.env")
testing.expect_value(t, host, "root@vertaxdev.com")
testing.expect_value(t, path, "/root/outline/docker.env")
}
@(test)
test_quote_remote_shell_path :: proc(t: ^testing.T) {
testing.expect_value(t, quote_remote_shell_path("/srv/app/.env"), "/srv/app/.env")
testing.expect_value(t, quote_remote_shell_path("/srv/app/a'b.env"), "/srv/app/a'\"'\"'b.env")
}
@(test)
test_quote_remote_shell_path_round_trips_without_injection :: proc(t: ^testing.T) {
when ODIN_OS == .Windows {
return
}
base := test_temp_dir(t, "envr-shell-quote-*")
defer os.remove_all(base)
marker, _ := filepath.join({base, "injected"}, context.temp_allocator)
quoted_marker := quote_remote_shell_path(marker)
paths := make([dynamic]string, 0, 10, context.temp_allocator)
append(&paths,
"",
"plain-name_123./:+@%-=,",
"spaces and\ttabs",
"single'quote and \"double quote\"",
"two''consecutive'''quotes",
fmt.tprintf("; touch '%s'; #", marker),
fmt.tprintf("$(touch '%s')", marker),
fmt.tprintf("`touch '%s'`", marker),
"&& || | < > ( ) $ ! * ? [ ] # ~ % { } \\",
"line one\nline two",
)
for path in paths {
if os.exists(marker) {
_ = os.remove(marker)
}
quoted_path := quote_remote_shell_path(path)
script := fmt.tprintf(
"printf '%%s' '%s'; if [ -e '%s' ]; then printf INJECTED; fi",
quoted_path,
quoted_marker,
)
desc := os.Process_Desc {
command = []string{"/bin/sh", "-c", script},
}
state, stdout, stderr, err := os.process_exec(desc, context.allocator)
delete(stdout)
delete(stderr)
testing.expectf(t, err == nil, "shell execution failed for %q: %v", path, err)
if err != nil {
return
}
testing.expectf(t, state.success, "shell failed for %q", path)
testing.expect_value(t, string(stdout), path)
testing.expectf(t, !os.exists(marker), "path %q executed the marker command", path)
}
}
@(test)
test_new_remote_env_file_owns_its_fields :: proc(t: ^testing.T) {
input_path := strings.clone("spencer@example.com:/etc/umami.env", context.allocator)
contents := make([]u8, len("SECRET=value"), context.allocator)
copy(contents, "SECRET=value")
file := new_remote_env_file(input_path, contents)
delete(input_path)
defer delete_envfile(&file)
testing.expect_value(t, file.path, "spencer@example.com:/etc/umami.env")
testing.expect_value(t, file.contents, "SECRET=value")
}
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.1.0