mirror of
https://github.com/sbrow/envr.git
synced 2026-10-25 08:41:55 -04:00
Compare commits
9
Commits
cf62869f3b
...
prerelease
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4637fcf3b | ||
|
|
d4c4ed8468 | ||
|
|
e0e9c93577 | ||
|
|
d62123e831 | ||
|
|
c50d490d9b | ||
|
|
a4c96265f8 | ||
|
|
3813719c84 | ||
|
|
18e6b0b5b1 | ||
|
|
149902cf91 |
@@ -1,5 +1,46 @@
|
||||
# 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)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* Renamed `nushell-completion` command to `completion nushell`.
|
||||
* Databases with a JSON formatted remotes column will no longer parse correctly. If you have an old database, make sure you upgrade to envr 0.4.0 and run `envr list` before upgrading to this version.
|
||||
|
||||
### Features
|
||||
|
||||
* Added `uninstall` command. ([222a0a3](https://github.com/sbrow/envr/commit/222a0a3452b978d9bd14d6d2b91e40cd8f4959e3))
|
||||
* Added bash completion support. ([e0a339e](https://github.com/sbrow/envr/commit/e0a339e8ec996c0e91e27357854abd9a78bd695a))
|
||||
* Brought back the docs generator. ([86a4da6](https://github.com/sbrow/envr/commit/86a4da6cbaae698379c73b8345e6d238b2a11a46))
|
||||
* Improved nushell wrapper output. ([209ec89](https://github.com/sbrow/envr/commit/209ec89addbed4718c5aed845ab20b386d1e425d))
|
||||
* Scan results can now be filtered during selection. ([ae42d53](https://github.com/sbrow/envr/commit/ae42d53f029dd185143a645fb3f3a787051c1bb4))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Removed irrelevent flags from `nushell-completion` command help text. ([af1860c](https://github.com/sbrow/envr/commit/af1860c970604470992182bbfe9ecb15609a0acf))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* Added `#no_bounds_check` to ssh parsers. ([f5624e9](https://github.com/sbrow/envr/commit/f5624e91f1236eecb263f28600ce7f4b2fda149f))
|
||||
* Added a pre-alloc opportunity. ([997f11b](https://github.com/sbrow/envr/commit/997f11b257f1c981f73c6ae97d6e821147b5e2fc))
|
||||
* Removed support for JSON formatted remotes. ([5282a44](https://github.com/sbrow/envr/commit/5282a4414b9a0211604553f2cb9434025aa5296b))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* Renamed `nushell-completion` command to `completion nushell`. ([b0635e0](https://github.com/sbrow/envr/commit/b0635e035c44fb20f9b02265cff1ed8db30aa9d0))
|
||||
|
||||
## [0.4.0](https://github.com/sbrow/envr/compare/v0.3.0...v0.4.0) (2026-06-29)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
the tool [of your choosing](#backup-options).
|
||||
|
||||
Please read the [AI Disclosure](#ai-disclosure) if you're concerned about AI code.
|
||||
|
||||
## Features
|
||||
|
||||
- **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).
|
||||
|
||||
## 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
|
||||
|
||||
This project is licensed under the [MIT License](./LICENSE).
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -97,19 +99,24 @@ key somewhere, otherwise your data could be lost forever.`,
|
||||
short = "Import a .env file into envr",
|
||||
aliases = {"add"},
|
||||
flags = GLOBAL_FLAGS,
|
||||
args = {{name = "path", completion = "untracked-paths", desc = "Path to .env file to backup"}},
|
||||
args = {
|
||||
{name = "path", completion = "untracked-paths", desc = "Path to .env file to backup"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "restore",
|
||||
usage = "envr restore <path>",
|
||||
short = "Restore a .env file from the database",
|
||||
flags = GLOBAL_FLAGS,
|
||||
args = {{name = "path", completion = "tracked-paths", desc = "Path to .env file to restore"}},
|
||||
flags = GLOBAL_FLAGS + {.Force},
|
||||
args = {
|
||||
{name = "path", completion = "tracked-paths", desc = "Path to .env file to restore"},
|
||||
},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
@@ -117,14 +124,37 @@ key somewhere, otherwise your data could be lost forever.`,
|
||||
usage = "envr remove <path>",
|
||||
short = "Remove a .env file from your database",
|
||||
flags = GLOBAL_FLAGS,
|
||||
args = {{name = "path", completion = "tracked-paths", desc = "Path to .env file to remove"}},
|
||||
args = {
|
||||
{name = "path", completion = "tracked-paths", desc = "Path to .env file to remove"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "check",
|
||||
usage = "envr check [path]",
|
||||
short = "Check if files are backed up",
|
||||
flags = GLOBAL_FLAGS,
|
||||
args = {{name = "path", optional = true, desc = "Path to check (defaults to current directory)"}},
|
||||
args = {
|
||||
{
|
||||
name = "path",
|
||||
optional = true,
|
||||
desc = "Path to check (defaults to current directory)",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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}},
|
||||
{
|
||||
@@ -142,7 +172,14 @@ key somewhere, otherwise your data could be lost forever.`,
|
||||
nushell
|
||||
bash`,
|
||||
flags = {.Help},
|
||||
args = {{name = "shell", ntype = "string", completion = "shells", desc = "Shell to generate completions for"}},
|
||||
args = {
|
||||
{
|
||||
name = "shell",
|
||||
ntype = "string",
|
||||
completion = "shells",
|
||||
desc = "Shell to generate completions for",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name = "uninstall",
|
||||
@@ -271,48 +308,22 @@ Flag_Field :: struct {
|
||||
completion: string,
|
||||
}
|
||||
|
||||
flag_field :: proc(ft: Flag_Type) -> Flag_Field {
|
||||
field := reflect.struct_field_at(Flags, int(ft))
|
||||
|
||||
args_tag := reflect.struct_tag_get(field.tag, "args")
|
||||
long_name, _ := strings.replace(field.name, "_", "-", -1, context.temp_allocator)
|
||||
if n, ok := get_subtag(args_tag, "name"); ok {
|
||||
long_name = n
|
||||
write_flags_table :: proc(tbl: ^table.Table, flags: bit_set[Flag_Type]) {
|
||||
table.caption(tbl, "Flags:")
|
||||
for ft in Flag_Type {
|
||||
if ft not_in flags do continue
|
||||
names, hint, desc := flag_field_info(ft)
|
||||
if len(hint) > 0 {
|
||||
display := table.format(
|
||||
tbl,
|
||||
"%s%s",
|
||||
colorize(.Flag, names, tbl.format_allocator),
|
||||
hint,
|
||||
)
|
||||
table.row(tbl, display, desc)
|
||||
} else {
|
||||
table.row(tbl, colorize(.Flag, names, tbl.format_allocator), desc)
|
||||
}
|
||||
|
||||
short, has_short := get_subtag(args_tag, "short")
|
||||
|
||||
base_ti := runtime.type_info_base(field.type)
|
||||
kind: Flag_Kind
|
||||
enum_values: string
|
||||
|
||||
if _, is_bool := base_ti.variant.(runtime.Type_Info_Boolean); is_bool {
|
||||
kind = .Bool
|
||||
} else if _, is_string := base_ti.variant.(runtime.Type_Info_String); is_string {
|
||||
kind = .String
|
||||
} else if enum_ti, is_enum := base_ti.variant.(runtime.Type_Info_Enum); is_enum {
|
||||
kind = .Enum
|
||||
parts := make([dynamic]string, 0, len(enum_ti.names), context.temp_allocator)
|
||||
for name in enum_ti.names {
|
||||
lower := strings.to_lower(name, context.temp_allocator)
|
||||
append(&parts, fmt.tprintf("'%s'", lower))
|
||||
}
|
||||
enum_values = strings.join(parts[:], "|", context.temp_allocator)
|
||||
delete(parts)
|
||||
}
|
||||
|
||||
usage := reflect.struct_tag_get(field.tag, "usage")
|
||||
default_val := reflect.struct_tag_get(field.tag, "default")
|
||||
completion := reflect.struct_tag_get(field.tag, "completion")
|
||||
|
||||
return {
|
||||
long_name = long_name,
|
||||
short_name = has_short ? short : "",
|
||||
kind = kind,
|
||||
usage = usage,
|
||||
default_val = default_val,
|
||||
enum_values = enum_values,
|
||||
completion = completion,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,22 +366,48 @@ flag_field_info :: proc(
|
||||
return
|
||||
}
|
||||
|
||||
write_flags_table :: proc(tbl: ^table.Table, flags: bit_set[Flag_Type]) {
|
||||
table.caption(tbl, "Flags:")
|
||||
for ft in Flag_Type {
|
||||
if ft not_in flags do continue
|
||||
names, hint, desc := flag_field_info(ft)
|
||||
if len(hint) > 0 {
|
||||
display := table.format(
|
||||
tbl,
|
||||
"%s%s",
|
||||
colorize(.Flag, names, tbl.format_allocator),
|
||||
hint,
|
||||
)
|
||||
table.row(tbl, display, desc)
|
||||
} else {
|
||||
table.row(tbl, colorize(.Flag, names, tbl.format_allocator), desc)
|
||||
flag_field :: proc(ft: Flag_Type) -> Flag_Field {
|
||||
field := reflect.struct_field_at(Flags, int(ft))
|
||||
|
||||
args_tag := reflect.struct_tag_get(field.tag, "args")
|
||||
long_name, _ := strings.replace(field.name, "_", "-", -1, context.temp_allocator)
|
||||
if n, ok := get_subtag(args_tag, "name"); ok {
|
||||
long_name = n
|
||||
}
|
||||
|
||||
short, has_short := get_subtag(args_tag, "short")
|
||||
|
||||
base_ti := runtime.type_info_base(field.type)
|
||||
kind: Flag_Kind
|
||||
enum_values: string
|
||||
|
||||
if _, is_bool := base_ti.variant.(runtime.Type_Info_Boolean); is_bool {
|
||||
kind = .Bool
|
||||
} else if _, is_string := base_ti.variant.(runtime.Type_Info_String); is_string {
|
||||
kind = .String
|
||||
} else if enum_ti, is_enum := base_ti.variant.(runtime.Type_Info_Enum); is_enum {
|
||||
kind = .Enum
|
||||
parts := make([dynamic]string, 0, len(enum_ti.names), context.temp_allocator)
|
||||
for name in enum_ti.names {
|
||||
lower := strings.to_lower(name, context.temp_allocator)
|
||||
append(&parts, fmt.tprintf("'%s'", lower))
|
||||
}
|
||||
enum_values = strings.join(parts[:], "|", context.temp_allocator)
|
||||
delete(parts)
|
||||
}
|
||||
|
||||
usage := reflect.struct_tag_get(field.tag, "usage")
|
||||
default_val := reflect.struct_tag_get(field.tag, "default")
|
||||
completion := reflect.struct_tag_get(field.tag, "completion")
|
||||
|
||||
return {
|
||||
long_name = long_name,
|
||||
short_name = has_short ? short : "",
|
||||
kind = kind,
|
||||
usage = usage,
|
||||
default_val = default_val,
|
||||
enum_values = enum_values,
|
||||
completion = completion,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-8
@@ -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)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import "core:fmt"
|
||||
import "core:terminal/ansi"
|
||||
|
||||
cmd_init :: proc(cmd: ^Command) {
|
||||
force := cmd.flags.force
|
||||
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-1
@@ -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")
|
||||
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"),
|
||||
|
||||
+9
-3
@@ -16,11 +16,18 @@ cmd_remove :: proc(cmd: ^Command) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -16,12 +16,18 @@ cmd_restore :: proc(cmd: ^Command) {
|
||||
fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
|
||||
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)
|
||||
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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+6
-5
@@ -221,7 +221,8 @@ search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]st
|
||||
include = expanded
|
||||
} else {
|
||||
// TODO: show errors?
|
||||
resolved, err := filepath.abs(expanded, allocator)
|
||||
resolved: string
|
||||
resolved, err = filepath.abs(expanded, allocator)
|
||||
if err == nil {
|
||||
include = resolved
|
||||
}
|
||||
@@ -231,10 +232,6 @@ search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]st
|
||||
return result
|
||||
}
|
||||
|
||||
envr_dir :: proc(config_path: string) -> string {
|
||||
return filepath.dir(config_path)
|
||||
}
|
||||
|
||||
// User is responsible for freeing the path
|
||||
data_path :: proc(
|
||||
config_path: string,
|
||||
@@ -246,3 +243,7 @@ data_path :: proc(
|
||||
return filepath.join([]string{envr_dir(config_path), "data.envr"}, allocator)
|
||||
}
|
||||
|
||||
envr_dir :: proc(config_path: string) -> string {
|
||||
return filepath.dir(config_path)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package main
|
||||
|
||||
import "base:runtime"
|
||||
import "core:crypto/hash"
|
||||
import "core:encoding/hex"
|
||||
import "core:encoding/ini"
|
||||
@@ -8,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"
|
||||
@@ -20,6 +20,12 @@ SyncFlagEnum :: enum {
|
||||
|
||||
SyncFlag :: bit_set[SyncFlagEnum]
|
||||
|
||||
Remote_Read_Status :: enum {
|
||||
Ok,
|
||||
Missing,
|
||||
Failed,
|
||||
}
|
||||
|
||||
SyncError :: enum {
|
||||
None,
|
||||
DirMissing,
|
||||
@@ -207,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,
|
||||
@@ -317,7 +323,11 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
|
||||
//
|
||||
// Expects an absolute path
|
||||
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))
|
||||
}
|
||||
|
||||
sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?"
|
||||
stmt: sqlite.Stmt
|
||||
@@ -400,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)
|
||||
@@ -429,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 {
|
||||
@@ -481,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) {
|
||||
@@ -592,4 +876,3 @@ clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string {
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ Import a .env file into envr
|
||||
envr backup <path> [flags]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
### Arguments
|
||||
|
||||
* `path` — Path to .env file to backup
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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,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]
|
||||
```
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,8 +2,6 @@
|
||||
|
||||
Remove envr configuration and database
|
||||
|
||||
### Synopsis
|
||||
|
||||
This will remove all files generated by envr from your system.
|
||||
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
+58
-53
@@ -4,64 +4,12 @@ import "base:runtime"
|
||||
import "core:reflect"
|
||||
import "core:strings"
|
||||
|
||||
get_subtag :: proc(tag: string, id: string) -> (value: string, ok: bool) {
|
||||
parts := strings.split(tag, ",", context.temp_allocator)
|
||||
for part in parts {
|
||||
trimmed := strings.trim_space(part)
|
||||
if strings.has_prefix(trimmed, id) && len(trimmed) > len(id) && trimmed[len(id)] == '=' {
|
||||
return trimmed[len(id) + 1:], true
|
||||
}
|
||||
if trimmed == id {
|
||||
return "", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
is_bool_type :: proc(field: reflect.Struct_Field) -> bool {
|
||||
base_ti := runtime.type_info_base(field.type)
|
||||
_, is_bool := base_ti.variant.(runtime.Type_Info_Boolean)
|
||||
return is_bool
|
||||
}
|
||||
|
||||
set_field :: proc(model: rawptr, field: reflect.Struct_Field, value: string) -> bool {
|
||||
ptr := rawptr(uintptr(model) + field.offset)
|
||||
base_ti := runtime.type_info_base(field.type)
|
||||
|
||||
if _, is_bool := base_ti.variant.(runtime.Type_Info_Boolean); is_bool {
|
||||
(cast(^bool)ptr)^ = true
|
||||
return true
|
||||
}
|
||||
|
||||
if _, is_string := base_ti.variant.(runtime.Type_Info_String); is_string {
|
||||
(cast(^string)ptr)^ = value
|
||||
return true
|
||||
}
|
||||
|
||||
if enum_ti, is_enum := base_ti.variant.(runtime.Type_Info_Enum); is_enum {
|
||||
for name, i in enum_ti.names {
|
||||
if strings.equal_fold(value, name) {
|
||||
v := enum_ti.values[i]
|
||||
switch base_ti.size {
|
||||
case 1: (cast(^u8)ptr)^ = cast(u8)v
|
||||
case 2: (cast(^u16)ptr)^ = cast(u16)v
|
||||
case 4: (cast(^u32)ptr)^ = cast(u32)v
|
||||
case 8: (cast(^u64)ptr)^ = cast(u64)v
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
parse_flags :: proc(model: ^$T, args: []string) -> (overflow: []string) {
|
||||
field_count := reflect.struct_field_count(T)
|
||||
long_map := make(map[string]reflect.Struct_Field, field_count, context.temp_allocator)
|
||||
short_map := make(map[string]reflect.Struct_Field, field_count, context.temp_allocator)
|
||||
|
||||
for i in 0..<field_count {
|
||||
for i in 0 ..< field_count {
|
||||
field := reflect.struct_field_at(T, i)
|
||||
|
||||
name, _ := strings.replace(field.name, "_", "-", -1, context.temp_allocator)
|
||||
@@ -132,3 +80,60 @@ parse_flags :: proc(model: ^$T, args: []string) -> (overflow: []string) {
|
||||
|
||||
return overflow_dyn[:]
|
||||
}
|
||||
|
||||
get_subtag :: proc(tag: string, id: string) -> (value: string, ok: bool) {
|
||||
parts := strings.split(tag, ",", context.temp_allocator)
|
||||
for part in parts {
|
||||
trimmed := strings.trim_space(part)
|
||||
if strings.has_prefix(trimmed, id) && len(trimmed) > len(id) && trimmed[len(id)] == '=' {
|
||||
return trimmed[len(id) + 1:], true
|
||||
}
|
||||
if trimmed == id {
|
||||
return "", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
is_bool_type :: proc(field: reflect.Struct_Field) -> bool {
|
||||
base_ti := runtime.type_info_base(field.type)
|
||||
_, is_bool := base_ti.variant.(runtime.Type_Info_Boolean)
|
||||
return is_bool
|
||||
}
|
||||
|
||||
set_field :: proc(model: rawptr, field: reflect.Struct_Field, value: string) -> bool {
|
||||
ptr := rawptr(uintptr(model) + field.offset)
|
||||
base_ti := runtime.type_info_base(field.type)
|
||||
|
||||
if _, is_bool := base_ti.variant.(runtime.Type_Info_Boolean); is_bool {
|
||||
(cast(^bool)ptr)^ = true
|
||||
return true
|
||||
}
|
||||
|
||||
if _, is_string := base_ti.variant.(runtime.Type_Info_String); is_string {
|
||||
(cast(^string)ptr)^ = value
|
||||
return true
|
||||
}
|
||||
|
||||
if enum_ti, is_enum := base_ti.variant.(runtime.Type_Info_Enum); is_enum {
|
||||
for name, i in enum_ti.names {
|
||||
if strings.equal_fold(value, name) {
|
||||
v := enum_ti.values[i]
|
||||
switch base_ti.size {
|
||||
case 1:
|
||||
(cast(^u8)ptr)^ = cast(u8)v
|
||||
case 2:
|
||||
(cast(^u16)ptr)^ = cast(u16)v
|
||||
case 4:
|
||||
(cast(^u32)ptr)^ = cast(u32)v
|
||||
case 8:
|
||||
(cast(^u64)ptr)^ = cast(u64)v
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
+15
-15
@@ -1,6 +1,5 @@
|
||||
package main
|
||||
|
||||
import "core:fmt"
|
||||
import "core:io"
|
||||
import "core:text/table"
|
||||
|
||||
@@ -18,20 +17,6 @@ decorations := table.Decorations {
|
||||
"─",
|
||||
}
|
||||
|
||||
ansi_aware_width :: proc(str: string) -> int #no_bounds_check {
|
||||
width := 0
|
||||
for i := 0; i < len(str); {
|
||||
if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' {
|
||||
i += 2
|
||||
for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}}
|
||||
} else {
|
||||
width += 1
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
write_borderless_table :: proc(w: io.Writer, t: ^table.Table) {
|
||||
table.build(t, ansi_aware_width)
|
||||
|
||||
@@ -64,6 +49,21 @@ write_borderless_table :: proc(w: io.Writer, t: ^table.Table) {
|
||||
write_table_separator(w, t)
|
||||
}
|
||||
|
||||
ansi_aware_width :: proc(str: string) -> int #no_bounds_check {
|
||||
width := 0
|
||||
for i := 0; i < len(str); {
|
||||
if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' {
|
||||
i += 2
|
||||
for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}}
|
||||
} else {
|
||||
width += 1
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
|
||||
table_reset :: proc(t: ^table.Table) {
|
||||
clear(&t.cells)
|
||||
clear(&t.colw)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.4.0
|
||||
1.1.0
|
||||
|
||||
Reference in New Issue
Block a user