6 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
spencer d62123e831 docs(README.md): Fixed broken link. 2026-07-27 17:43:39 -04:00
spencer c50d490d9b docs: Added AI Disclaimer. 2026-07-27 17:42:21 -04:00
spencer a4c96265f8 chore: Added TODOs. 2026-07-27 13:53:20 -04:00
30 changed files with 1016 additions and 48 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # 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) ## [1.0.0](https://github.com/sbrow/envr/compare/v0.4.0...v1.0.0) (2026-07-27)
+18
View File
@@ -10,6 +10,8 @@ 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
@@ -125,6 +127,22 @@ 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).
+7
View File
@@ -12,6 +12,13 @@
7. Add purge command? 7. Add purge command?
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 ## 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"`, 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"`, color: Color_Mode `usage:"Whether or not to colorize output" default:"auto" completion:"color"`,
force: bool `args:"short=f" usage:"Overwrite existing config"`, 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 { Flag_Type :: enum {
@@ -33,6 +34,7 @@ Flag_Type :: enum {
Output, Output,
Color, Color,
Force, Force,
Stdout,
} }
Output_Format :: enum { Output_Format :: enum {
@@ -105,7 +107,7 @@ key somewhere, otherwise your data could be lost forever.`,
name = "restore", name = "restore",
usage = "envr restore <path>", usage = "envr restore <path>",
short = "Restore a .env file from the database", short = "Restore a .env file from the database",
flags = GLOBAL_FLAGS, flags = GLOBAL_FLAGS + {.Force},
args = { args = {
{name = "path", completion = "tracked-paths", desc = "Path to .env file to restore"}, {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", name = "list",
usage = "envr list", usage = "envr list",
short = "View your tracked files", 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}, 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 = "version", usage = "envr version", short = "Show envr's version", flags = {.Help}},
{ {
name = "edit-config", name = "edit-config",
+23 -8
View File
@@ -7,22 +7,29 @@ import "core:path/filepath"
// TODO: What happens if you pass a non existent path to cmd_check? // 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. // 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 input_path: string
if len(cmd.args) > 0 { if len(cmd.args) > 0 {
_check_path = cmd.args[0] input_path = cmd.args[0]
} else { } else {
cwd, cwd_err := os.get_working_directory(context.temp_allocator) cwd, cwd_err := os.get_working_directory(context.temp_allocator)
if cwd_err != nil { if cwd_err != nil {
fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false) fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false)
return return
} }
_check_path = cwd input_path = cwd
} }
check_path, abs_err := filepath.abs(_check_path, context.temp_allocator) 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 { if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
resolved_path = abs_path
}
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open(cmd.flags.config_file)
if !db_ok { if !db_ok {
@@ -30,20 +37,23 @@ cmd_check :: proc(cmd: ^Command) {
} }
defer db_close(&db) 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 // TODO: set a reasonable default
files_in_path := make([dynamic]string, context.temp_allocator) files_in_path := make([dynamic]string, context.temp_allocator)
if is_dir { if is_dir {
scanned, scan_ok := scan_path(check_path, db.cfg) scanned, scan_ok := scan_path(resolved_path, db.cfg)
if !scan_ok { if !scan_ok {
fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false) fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false)
return return
} }
files_in_path = scanned files_in_path = scanned
} else { } else {
append(&files_in_path, check_path) append(&files_in_path, resolved_path)
} }
db_files, list_ok := db_list(&db) db_files, list_ok := db_list(&db)
@@ -56,6 +66,8 @@ 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.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 { } else {
fmt.wprintln( fmt.wprintln(
cmd.out, cmd.out,
@@ -73,7 +85,10 @@ cmd_check :: proc(cmd: ^Command) {
for file in not_backed { for file in not_backed {
fmt.wprintf(cmd.out, " %s\n", file, flush = false) fmt.wprintf(cmd.out, " %s\n", file, 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) fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false)
} }
} }
}
+43
View File
@@ -2,6 +2,10 @@
package main package main
import "core:testing" import "core:testing"
import "core:bufio"
import "core:os"
import "core:path/filepath"
import "core:strings"
@(test) @(test)
test_find_unbacked_finds_missing :: proc(t: ^testing.T) { 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) 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( table.aligned_header_of_values(
&t, &t,
.Center, .Center,
colorize(.Table_Heading, "#"),
colorize(.Table_Heading, "Directory"), colorize(.Table_Heading, "Directory"),
colorize(.Table_Heading, "Path"), colorize(.Table_Heading, "Path"),
) )
for row in rows { for row, i in rows {
dir_str := strings.concatenate( dir_str := strings.concatenate(
{row.dir, os.Path_Separator_String}, {row.dir, os.Path_Separator_String},
context.temp_allocator, context.temp_allocator,
) )
filename := filepath.base(row.path) 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) 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) fmt.wprintln(cmd.out, string(data), flush = false)
} }
} }
+18 -1
View File
@@ -80,9 +80,12 @@ test_cmd_list_output_table :: proc(t: ^testing.T) {
db, db_ok := db_open(cfg_path) db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open") testing.expect(t, db_ok, "db should open")
if !db_ok do return if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value") 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) defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed") testing.expect(t, db_insert(&db, f), "insert should succeed")
}
db_close(&db) db_close(&db)
out_b: strings.Builder out_b: strings.Builder
@@ -106,6 +109,20 @@ test_cmd_list_output_table :: proc(t: ^testing.T) {
output := strings.to_string(out_b) 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 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( testing.expect(
t, t,
strings.contains(output, "Directory"), strings.contains(output, "Directory"),
+9 -3
View File
@@ -16,11 +16,18 @@ cmd_remove :: proc(cmd: ^Command) {
return return
} }
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) abs_path, abs_err := filepath.abs(path, context.temp_allocator)
if abs_err != nil { if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
resolved_path = abs_path
}
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open(cmd.flags.config_file)
if !db_ok { if !db_ok {
@@ -28,10 +35,9 @@ cmd_remove :: proc(cmd: ^Command) {
} }
defer db_close(&db) defer db_close(&db)
if !db_delete(&db, abs_path) { if !db_delete(&db, resolved_path) {
return 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)
} }
+15 -2
View File
@@ -16,12 +16,18 @@ cmd_restore :: proc(cmd: ^Command) {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false) fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return return
} }
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) abs_path, abs_err := filepath.abs(path, context.temp_allocator)
if abs_err != nil { if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
resolved_path = abs_path
}
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open(cmd.flags.config_file)
if !db_ok { if !db_ok {
@@ -29,10 +35,17 @@ cmd_restore :: proc(cmd: ^Command) {
} }
defer db_close(&db) defer db_close(&db)
file, fetch_ok := db_fetch(&db, abs_path) file, fetch_ok := db_fetch(&db, resolved_path)
if !fetch_ok { if !fetch_ok {
return 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) dir := filepath.dir(file.path)
if err := os.mkdir_all(dir); err != nil { 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")
}
+286 -2
View File
@@ -7,6 +7,7 @@ import "core:fmt"
import "core:mem" import "core:mem"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:path/slashpath"
import "core:strings" import "core:strings"
import "sqlite" import "sqlite"
@@ -19,6 +20,12 @@ SyncFlagEnum :: enum {
SyncFlag :: bit_set[SyncFlagEnum] SyncFlag :: bit_set[SyncFlagEnum]
Remote_Read_Status :: enum {
Ok,
Missing,
Failed,
}
SyncError :: enum { SyncError :: enum {
None, None,
DirMissing, DirMissing,
@@ -206,7 +213,7 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
stmt: sqlite.Stmt stmt: sqlite.Stmt
rc := sqlite.prepare_v2( rc := sqlite.prepare_v2(
db.conn, 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, -1,
&stmt, &stmt,
nil, nil,
@@ -316,7 +323,11 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
// //
// Expects an absolute path // Expects an absolute path
db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) { db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
if _, remote_path, is_remote := parse_remote_path(path); is_remote {
assert(slashpath.is_abs(remote_path))
} else {
assert(os.is_absolute_path(path)) assert(os.is_absolute_path(path))
}
sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?" sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?"
stmt: sqlite.Stmt stmt: sqlite.Stmt
@@ -399,6 +410,17 @@ db_delete :: proc(db: ^Db, path: string) -> bool {
// Caller is responsible for the returned memory // Caller is responsible for the returned memory
new_env_file :: proc(path: string) -> (EnvFile, bool) { 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) abs_path, abs_err := filepath.abs(path)
if abs_err != nil { if abs_err != nil {
fmt.eprintf("Error getting absolute path: %v\n", abs_err) fmt.eprintf("Error getting absolute path: %v\n", abs_err)
@@ -428,12 +450,66 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
true 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. // Reconciles `f` with the filesystem and persists changes to the database.
db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) { db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
allocator := db_allocator(db) allocator := db_allocator(db)
result: SyncFlag = {} result: SyncFlag = {}
old_path := f.path 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) { if !os.exists(f.dir) {
moved, err := try_move_dir(db, f, allocator) moved, err := try_move_dir(db, f, allocator)
if !moved { if !moved {
@@ -480,6 +556,215 @@ db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
return result + {.BackedUp}, .None 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 { db_persist :: proc(db: ^Db, f: ^EnvFile, old_path: string) -> bool {
if f.path != old_path { if f.path != old_path {
if !db_delete(db, old_path) { if !db_delete(db, old_path) {
@@ -591,4 +876,3 @@ clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string {
return str 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 list](envr_list.md) - View your tracked files
* [envr remove](envr_remove.md) - Remove a .env file from your database * [envr remove](envr_remove.md) - Remove a .env file from your database
* [envr check](envr_check.md) - Check if files are backed up * [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 version](envr_version.md) - Show envr's version
* [envr edit-config](envr_edit-config.md) - Edit your config with your default editor * [envr edit-config](envr_edit-config.md) - Edit your config with your default editor
* [envr completion](envr_completion.md) - Generate shell completion scripts * [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] envr backup <path> [flags]
``` ```
**Arguments:** ### Arguments
* `path` — Path to .env file to backup * `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] envr check [path] [flags]
``` ```
**Arguments:** ### Arguments
* `path` — Path to check (defaults to current directory) (optional) * `path` — Path to check (defaults to current directory) (optional)
+1 -3
View File
@@ -2,8 +2,6 @@
Generate shell completion scripts Generate shell completion scripts
### Synopsis
Supported shells: Supported shells:
nushell nushell
@@ -13,7 +11,7 @@ Supported shells:
envr completion <shell> [flags] envr completion <shell> [flags]
``` ```
**Arguments:** ### Arguments
* `shell` — Shell to generate completions for * `shell` — Shell to generate completions for
-2
View File
@@ -2,8 +2,6 @@
Set up envr Set up envr
### 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.\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 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 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] envr list [flags]
``` ```
+1 -1
View File
@@ -6,7 +6,7 @@ Remove a .env file from your database
envr remove <path> [flags] envr remove <path> [flags]
``` ```
**Arguments:** ### Arguments
* `path` — Path to .env file to remove * `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] envr restore <path> [flags]
``` ```
**Arguments:** ### Arguments
* `path` — Path to .env file to restore * `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 ### Options
``` ```
-h, --help show this documentation -h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json") -c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto') --color Whether or not to colorize output (default 'auto')
-f, --force Overwrite existing config
``` ```
### SEE ALSO ### SEE ALSO
+4
View File
@@ -2,6 +2,10 @@
Update or restore your env backups 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] envr sync [flags]
``` ```
-2
View File
@@ -2,8 +2,6 @@
Remove envr configuration and database Remove envr configuration and database
### Synopsis
This will remove all files generated by envr from your system. 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 = [ nativeBuildInputs = [
pkgs.unstable.odin pkgs.unstable.odin
pkgs.pkg-config pkgs.pkg-config
pkgs.makeWrapper
]; ];
buildInputs = [ buildInputs = [
pkgs.git pkgs.git
pkgs.openssh
pkgs.libsodium pkgs.libsodium
mysqlite mysqlite
@@ -108,6 +110,10 @@
cp docs/man/*.1 $out/share/man/man1/ cp docs/man/*.1 $out/share/man/man1/
runHook postInstall runHook postInstall
''; '';
postFixup = ''
wrapProgram $out/bin/envr --prefix PATH : ${nixpkgs.lib.makeBinPath [ pkgs.openssh ]}
'';
}; };
devShells.default = pkgs.mkShell { 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) fmt.sbprintf(&sb, "%s\n\n", c.short)
if len(c.long) > 0 { if len(c.long) > 0 {
fmt.sbprintf(&sb, "### Synopsis\n\n")
fmt.sbprint(&sb, c.long) fmt.sbprint(&sb, c.long)
fmt.sbprintf(&sb, "\n\n") 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) fmt.sbprintf(&sb, "```\n%s [flags]\n```\n\n", c.usage)
if len(c.args) > 0 { if len(c.args) > 0 {
fmt.sbprintf(&sb, "**Arguments:**\n\n") fmt.sbprintf(&sb, "### Arguments\n\n")
for arg in c.args { for arg in c.args {
optional := arg.optional ? " (optional)" : "" optional := arg.optional ? " (optional)" : ""
fmt.sbprintf(&sb, "* `%s` — %s%s\n", arg.name, arg.desc, 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) fmt.eprintf("Error writing %s: %v\n", path, err)
} }
} }
+2
View File
@@ -74,6 +74,8 @@ when !#config(GENDOCS, false) {
cmd_edit_config(&cmd) cmd_edit_config(&cmd)
case "check": case "check":
cmd_check(&cmd) cmd_check(&cmd)
case "view":
if !cmd_view(&cmd) do os.exit(1)
case "scan": case "scan":
cmd_scan(&cmd) cmd_scan(&cmd)
case "sync": case "sync":
+66 -1
View File
@@ -1,6 +1,7 @@
package main package main
import "core:fmt" import "core:fmt"
import "core:os"
import "core:strings" import "core:strings"
import "core:sys/posix" import "core:sys/posix"
import "core:terminal/ansi" import "core:terminal/ansi"
@@ -28,6 +29,71 @@ Raw_State :: struct {
MAX_VISIBLE :: 7 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. // Caller is responsible for deleting the responses.
multi_select :: proc( multi_select :: proc(
prompt: string, prompt: string,
@@ -263,4 +329,3 @@ read_key :: proc() -> (key: Key, ch: rune) {
return .Unknown, 0 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