mirror of
https://github.com/sbrow/envr.git
synced 2026-10-25 08:41:55 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf6cc9a60a |
@@ -1,13 +1,5 @@
|
|||||||
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ 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 {
|
||||||
@@ -34,7 +33,6 @@ Flag_Type :: enum {
|
|||||||
Output,
|
Output,
|
||||||
Color,
|
Color,
|
||||||
Force,
|
Force,
|
||||||
Stdout,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Output_Format :: enum {
|
Output_Format :: enum {
|
||||||
@@ -116,7 +114,6 @@ 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},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -141,21 +138,6 @@ 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",
|
||||||
@@ -503,4 +485,3 @@ delete_command :: proc(cmd: ^Command) {
|
|||||||
bufio.writer_destroy(cmd.out_buf)
|
bufio.writer_destroy(cmd.out_buf)
|
||||||
free(cmd.out_buf)
|
free(cmd.out_buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -32,19 +32,18 @@ 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, i in rows {
|
for row 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, fmt.tprintf("%d", i + 1), dir_str, filename)
|
table.row(&t, 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)
|
||||||
@@ -74,3 +73,4 @@ cmd_list :: proc(cmd: ^Command) {
|
|||||||
fmt.wprintln(cmd.out, string(data), flush = false)
|
fmt.wprintln(cmd.out, string(data), flush = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-20
@@ -80,12 +80,9 @@ 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
|
||||||
paths := []string{"/zeta/.env", "/alpha/.env", "/middle/.env"}
|
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
|
||||||
for path in paths {
|
defer delete(f.remotes)
|
||||||
f := make_test_env_file(path, "abc123", "SECRET=value")
|
testing.expect(t, db_insert(&db, f), "insert should succeed")
|
||||||
defer delete(f.remotes)
|
|
||||||
testing.expect(t, db_insert(&db, f), "insert should succeed")
|
|
||||||
}
|
|
||||||
db_close(&db)
|
db_close(&db)
|
||||||
|
|
||||||
out_b: strings.Builder
|
out_b: strings.Builder
|
||||||
@@ -109,20 +106,6 @@ 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"),
|
||||||
|
|||||||
-157
@@ -1,157 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
#+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")
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -20,12 +20,6 @@ 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,
|
||||||
@@ -213,7 +207,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 ORDER BY path COLLATE BINARY",
|
"SELECT path, remotes, sha256, contents FROM envr_env_files",
|
||||||
-1,
|
-1,
|
||||||
&stmt,
|
&stmt,
|
||||||
nil,
|
nil,
|
||||||
@@ -414,8 +408,8 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
|
|||||||
if !resolved {
|
if !resolved {
|
||||||
return EnvFile{}, false
|
return EnvFile{}, false
|
||||||
}
|
}
|
||||||
contents, read_status := read_remote_file(host, remote_path)
|
contents, read_ok := read_remote_file(host, remote_path)
|
||||||
if read_status != .Ok {
|
if !read_ok {
|
||||||
return EnvFile{}, false
|
return EnvFile{}, false
|
||||||
}
|
}
|
||||||
return new_remote_env_file(identity, contents), true
|
return new_remote_env_file(identity, contents), true
|
||||||
@@ -474,16 +468,10 @@ db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
|
|||||||
if !resolved {
|
if !resolved {
|
||||||
return {}, .ReadFailed
|
return {}, .ReadFailed
|
||||||
}
|
}
|
||||||
contents, read_status := read_remote_file(host, remote_path)
|
contents, read_ok := read_remote_file(host, remote_path)
|
||||||
if read_status == .Failed {
|
if !read_ok {
|
||||||
return {}, .ReadFailed
|
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)
|
digest := hash.hash_bytes(hash.Algorithm.SHA256, contents, context.temp_allocator)
|
||||||
hex_bytes := hex.encode(digest, allocator)
|
hex_bytes := hex.encode(digest, allocator)
|
||||||
@@ -647,9 +635,9 @@ remote_home_directory :: proc(host: string) -> (home: string, ok: bool) {
|
|||||||
|
|
||||||
// Read a remote file through OpenSSH. The path is quoted for the remote shell;
|
// 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.
|
// 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) {
|
read_remote_file :: proc(host, remote_path: string) -> (contents: []byte, ok: bool) {
|
||||||
quoted_path := quote_remote_shell_path(remote_path)
|
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)
|
remote_command := fmt.tprintf("cat -- '%s'", quoted_path)
|
||||||
desc := os.Process_Desc {
|
desc := os.Process_Desc {
|
||||||
command = []string{"ssh", "-T", "--", host, remote_command},
|
command = []string{"ssh", "-T", "--", host, remote_command},
|
||||||
stdin = nil,
|
stdin = nil,
|
||||||
@@ -659,19 +647,15 @@ read_remote_file :: proc(host, remote_path: string) -> (contents: []byte, status
|
|||||||
fmt.eprintf("Error starting ssh: %v\n", err)
|
fmt.eprintf("Error starting ssh: %v\n", err)
|
||||||
delete(stdout)
|
delete(stdout)
|
||||||
delete(stderr)
|
delete(stderr)
|
||||||
return nil, .Failed
|
return nil, false
|
||||||
}
|
}
|
||||||
delete(stderr)
|
delete(stderr)
|
||||||
if !state.success {
|
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)
|
fmt.eprintf("Error reading remote file via ssh (exit code %d)\n", state.exit_code)
|
||||||
delete(stdout)
|
delete(stdout)
|
||||||
return nil, .Failed
|
return nil, false
|
||||||
}
|
}
|
||||||
return stdout, .Ok
|
return stdout, true
|
||||||
}
|
}
|
||||||
|
|
||||||
quote_remote_shell_path :: proc(path: string) -> string {
|
quote_remote_shell_path :: proc(path: string) -> string {
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Generate shell completion scripts
|
Generate shell completion scripts
|
||||||
|
|
||||||
|
### Synopsis
|
||||||
|
|
||||||
Supported shells:
|
Supported shells:
|
||||||
|
|
||||||
nushell
|
nushell
|
||||||
@@ -11,7 +13,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,6 +2,8 @@
|
|||||||
|
|
||||||
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,8 +2,6 @@
|
|||||||
|
|
||||||
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]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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,7 +6,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,6 @@
|
|||||||
|
|
||||||
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,6 +2,8 @@
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
## 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.
|
|
||||||
+3
-1
@@ -201,6 +201,7 @@ 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")
|
||||||
}
|
}
|
||||||
@@ -208,7 +209,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)
|
||||||
@@ -285,3 +286,4 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,6 @@ 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":
|
||||||
|
|||||||
+1
-66
@@ -1,7 +1,6 @@
|
|||||||
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"
|
||||||
@@ -29,71 +28,6 @@ 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,
|
||||||
@@ -329,3 +263,4 @@ read_key :: proc() -> (key: Key, ch: rune) {
|
|||||||
return .Unknown, 0
|
return .Unknown, 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.1.0
|
1.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user