36 Commits

Author SHA1 Message Date
Spencer Brower cf62869f3b chore(prerelease): release 1.0.0 2026-07-27 13:15:35 -04:00
spencer 86a4da6cba feat: Brought back the docs generator. 2026-07-27 13:12:31 -04:00
spencer 222a0a3452 feat: Added uninstall command. 2026-07-27 13:12:31 -04:00
spencer d4c935ec07 test: Added a test to ensure db updates don't break hardlinks. 2026-07-13 13:54:06 -04:00
spencer e0a339e8ec feat: Added bash completion support. 2026-07-13 13:35:05 -04:00
spencer b0635e035c refactor!: Renamed nushell-completion command to completion nushell. 2026-07-13 13:12:59 -04:00
spencer 997f11b257 perf: Added a pre-alloc opportunity. 2026-07-03 22:12:08 -04:00
spencer f5624e91f1 perf: Added #no_bounds_check to ssh parsers. 2026-07-03 21:43:06 -04:00
spencer 80353f387a chore: Updated todos. 2026-07-01 17:01:21 -04:00
spencer aae1495d59 refactor: Declared commands with named fields instead of positional. 2026-07-01 14:02:36 -04:00
spencer 209ec89add feat: Improved nushell wrapper output. 2026-07-01 14:02:36 -04:00
spencer 7ffc38171c chore: Updated TODOS.md 2026-07-01 11:23:42 -04:00
spencer 8b9a9789ab refactor: Removed needless guards from write_flags_table. 2026-07-01 11:11:24 -04:00
spencer 6244a6c8ce refactor(ssh.odin): removed offset from parser logic. 2026-07-01 10:49:36 -04:00
spencer 7aaa415777 test: Fixed leak in test. 2026-06-30 13:27:38 -04:00
spencer ca992f05f4 refactor(findr): Now accepts an allocator parameter. 2026-06-30 13:27:38 -04:00
spencer ae42d53f02 feat: Scan results can now be filtered during selection. 2026-06-30 12:31:28 -04:00
spencer af1860c970 fix: Removed irrelevent flags from nushell-completion command help text. 2026-06-29 18:17:56 -04:00
spencer 5282a4414b perf!: Removed support for JSON formatted remotes.
BREAKING CHANGE: 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.
2026-06-29 18:12:11 -04:00
spencer ea1627ec2e build: nix version is now pinned to version.txt. 2026-06-29 18:12:10 -04:00
Spencer Brower d978cb6869 chore(main): release 0.4.0 2026-06-29 17:38:20 -04:00
spencer 70b5c0e184 ci: Updated release-please. 2026-06-29 17:32:55 -04:00
spencer fb90342126 fix: Flags in help text are now customized per command. 2026-06-29 17:32:55 -04:00
spencer 17d7fb5eb6 refactor: Removed hardcoded colors. 2026-06-29 17:32:55 -04:00
spencer c3e667e7bc feat: Added --color flag. 2026-06-29 17:32:55 -04:00
spencer 16819f65cb refactor: Used RTTI for more sophisticated flag parsing. 2026-06-29 17:32:55 -04:00
spencer 4d3da01f65 nix: Fixed the nix build. 2026-06-29 17:32:54 -04:00
spencer 2b68617b5d fix: -h short flag now works on subcommands. 2026-06-29 17:32:54 -04:00
spencer 36f124a4fd chore: Re-numbered todos. 2026-06-29 17:32:54 -04:00
spencer bb84c56c98 feat: Added --format, -f flag.
Allows printing data in tabular or json format.
2026-06-29 17:32:54 -04:00
spencer 6fa68d10b1 style: Ignored allocation errors where possible. 2026-06-25 17:34:34 -04:00
spencer 13e9495642 refactor: Replaced fmt.printf calls with fmt.eprintf. 2026-06-25 17:21:14 -04:00
spencer ad3de74e35 test: Rewrote expect checks to use expect_value where appropriate. 2026-06-25 17:13:50 -04:00
spencer 0b5bf4db73 perf: Improved the performance of table rendering. 2026-06-25 10:22:00 -04:00
spencer 96b3d6340a perf: remotes are now stored as a newline delimited list.
Previously they were saved as json.
2026-06-25 10:22:00 -04:00
spencer 5cc7973775 fix: Used os path separator rather than '/' where appropriate. 2026-06-24 17:55:54 -04:00
65 changed files with 2384 additions and 949 deletions
+2
View File
@@ -1 +1,3 @@
use flake use flake
export PATH="./:$PATH"
+1
View File
@@ -2,6 +2,7 @@ on:
push: push:
branches: branches:
- main - main
- prerelease
permissions: permissions:
contents: write contents: write
+1
View File
@@ -15,5 +15,6 @@ envr-prof
findr/findr findr/findr
findr/findr-prof findr/findr-prof
findr/bench-*.md findr/bench-*.md
gendocs
result result
version.odin version.odin
+64
View File
@@ -1,5 +1,69 @@
# Changelog # Changelog
## [1.0.0](https://github.com/sbrow/envr/compare/v0.4.0...v1.0.0) (2026-07-27)
### ⚠ BREAKING CHANGES
* Renamed `nushell-completion` command to `completion nushell`.
* Databases with a JSON formatted remotes column will no longer parse correctly. If you have an old database, make sure you upgrade to envr 0.4.0 and run `envr list` before upgrading to this version.
### Features
* Added `uninstall` command. ([222a0a3](https://github.com/sbrow/envr/commit/222a0a3452b978d9bd14d6d2b91e40cd8f4959e3))
* Added bash completion support. ([e0a339e](https://github.com/sbrow/envr/commit/e0a339e8ec996c0e91e27357854abd9a78bd695a))
* Brought back the docs generator. ([86a4da6](https://github.com/sbrow/envr/commit/86a4da6cbaae698379c73b8345e6d238b2a11a46))
* Improved nushell wrapper output. ([209ec89](https://github.com/sbrow/envr/commit/209ec89addbed4718c5aed845ab20b386d1e425d))
* Scan results can now be filtered during selection. ([ae42d53](https://github.com/sbrow/envr/commit/ae42d53f029dd185143a645fb3f3a787051c1bb4))
### Bug Fixes
* Removed irrelevent flags from `nushell-completion` command help text. ([af1860c](https://github.com/sbrow/envr/commit/af1860c970604470992182bbfe9ecb15609a0acf))
### Performance Improvements
* Added `#no_bounds_check` to ssh parsers. ([f5624e9](https://github.com/sbrow/envr/commit/f5624e91f1236eecb263f28600ce7f4b2fda149f))
* Added a pre-alloc opportunity. ([997f11b](https://github.com/sbrow/envr/commit/997f11b257f1c981f73c6ae97d6e821147b5e2fc))
* Removed support for JSON formatted remotes. ([5282a44](https://github.com/sbrow/envr/commit/5282a4414b9a0211604553f2cb9434025aa5296b))
### Code Refactoring
* Renamed `nushell-completion` command to `completion nushell`. ([b0635e0](https://github.com/sbrow/envr/commit/b0635e035c44fb20f9b02265cff1ed8db30aa9d0))
## [0.4.0](https://github.com/sbrow/envr/compare/v0.3.0...v0.4.0) (2026-06-29)
### Features
* Added `--color` flag. ([c3e667e](https://github.com/sbrow/envr/commit/c3e667e7bca660b21b5851460691ec8bd5026f80))
* Added `--format`, `-f` flag. ([bb84c56](https://github.com/sbrow/envr/commit/bb84c56c98bead9ce9bad3f89fb3b61b53d05a10))
* Colorized console output. ([33cd7c4](https://github.com/sbrow/envr/commit/33cd7c4eda43287fe3dc2d2289a4e1531b524ef6))
* Removed runtime git dependency. ([12574e1](https://github.com/sbrow/envr/commit/12574e123bdedba3aca813143e906ec5e0b95719))
### Bug Fixes
* -h short flag now works on subcommands. ([2b68617](https://github.com/sbrow/envr/commit/2b68617b5d6c17444b04307d7b4b7f4a3aefb978))
* Databases errors are less likely to go unnoticed. ([f825bc2](https://github.com/sbrow/envr/commit/f825bc2b096632bb258d5681b1072941e9d4233d))
* Fixed leaks. ([c7c254f](https://github.com/sbrow/envr/commit/c7c254f6f2ac871182f3fcc233766884753e2049))
* Fixed memory leaks in the db. ([5059572](https://github.com/sbrow/envr/commit/5059572951b3ec20b3d2027032a9c3be5cb14dba))
* Fixed some leaks in `backup` and `scan`. ([dc72ff5](https://github.com/sbrow/envr/commit/dc72ff56fd0e165930771682b2fd266eef3e7e16))
* Fixed vet errors. ([1562fb3](https://github.com/sbrow/envr/commit/1562fb3665b8704dcffd4944047f7984b308e52e))
* Flags in help text are now customized per command. ([fb90342](https://github.com/sbrow/envr/commit/fb903421265baa65adbbe092f6bf797639f1fb1f))
* Handled mk_dir error. ([de1594d](https://github.com/sbrow/envr/commit/de1594d9d1fe46dd9ebc3be01fc5a5ebaf4064e5))
* **scan:** Fixed a bug preventing TUI from working. ([0083e4e](https://github.com/sbrow/envr/commit/0083e4e0dbc5b949a3cf4cd8b64af4f698ed9d33))
* Used os path separator rather than '/' where appropriate. ([5cc7973](https://github.com/sbrow/envr/commit/5cc79737753f0f19db2bc55304ea5f70237cbffd))
### Performance Improvements
* Improved the performance of table rendering. ([0b5bf4d](https://github.com/sbrow/envr/commit/0b5bf4db73113bdd43d8a225ec0ae53fedd25918))
* remotes are now stored as a newline delimited list. ([96b3d63](https://github.com/sbrow/envr/commit/96b3d6340a95b67c884ba940b46461e51989fcbb))
* Replaced `fd` with custom internals. ([2ef733f](https://github.com/sbrow/envr/commit/2ef733fe58594b0a0b6e3ef85142b74af445ccb8))
## [0.3.0](https://github.com/sbrow/envr/compare/v0.2.1...v0.3.0) (2026-06-16) ## [0.3.0](https://github.com/sbrow/envr/compare/v0.2.1...v0.3.0) (2026-06-16)
Version 0.3.0 represents a significant departure (and improvement) for envr. Version 0.3.0 represents a significant departure (and improvement) for envr.
+1 -1
View File
@@ -2,7 +2,7 @@
# Builds release artifacts for GitHub releases # Builds release artifacts for GitHub releases
APP_NAME := envr APP_NAME := envr
VERSION := $(shell grep 'version = ' flake.nix | head -1 | sed 's/.*version = "\(.*\)";/\1/') VERSION := $(file < version.txt)
BUILD_DIR := builds BUILD_DIR := builds
# Binary names # Binary names
+8 -32
View File
@@ -1,42 +1,17 @@
# TODOs # TODOs
1. Commands are still leaking. 1. Bring back windows support / cross-compilation.
2. Add color flag and support non colored output. 2. Commands are still leaking. (Write tests for everything first)
3. Rewrite `write_command_help` to use text/tables 3. procedures should be ordered by use, main at the top, then in the order they are called from main.
4. Generate md and man pages again. 4. Test all command branches.
5. Json may be an expensive encoding for remotes. Confirm with spall, and use null terminated strings if necessary. 6. Add tests for untested commands.
6. Make sure official path separators are used when appropriate, rather than '/'. 7. Add purge command?
7. Consistently ignore allocator errors
8. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
9. Add a text filter to the multi_select.
10. Add tests for untested commands.
11. add --format -f flag to commands that draw tables.
12. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate.
13. procedures should be ordered by use, main at the top, then in the order they are called from main.
14. Shell completion
15. Bring back windows support / cross-compilation.
16. Test all cmds / terminal branches.
17. Fix error messages to use fmt.eprintf (stderr) instead of fmt.printf (stdout)
18. Pass allocator to findr?
19. Update `read_wire_string` to use a slice.
## Double-check AI output ## Double-check AI output
@@ -64,8 +39,9 @@
- [ ] db.odin - [ ] db.odin
- [ ] db_integration_test.odin - [ ] db_integration_test.odin
- [ ] db_test.odin - [ ] db_test.odin
- [ ] flags.odin
- [x] main.odin - [x] main.odin
- [x] prompt.odin - [ ] prompt.odin
- [x] scan.odin - [x] scan.odin
- [ ] scan_test.odin - [ ] scan_test.odin
- [ ] sodium.odin - [ ] sodium.odin
+292 -137
View File
@@ -1,21 +1,58 @@
package main package main
import "base:runtime"
import "core:bufio" import "core:bufio"
import "core:fmt" import "core:fmt"
import "core:io" import "core:io"
import "core:os" import "core:os"
import "core:reflect"
import "core:strings" import "core:strings"
import "core:terminal"
import "core:text/table" import "core:text/table"
Command :: struct { Command :: struct {
name: string, name: string,
args: [dynamic]string, args: [dynamic]string,
flags: map[string]string, flags: Flags,
bool_set: map[string]bool, out_buf: ^bufio.Writer,
config_path: string, out: io.Writer,
out_buf: ^bufio.Writer, err: io.Writer,
out: io.Writer, }
err: io.Writer,
Flags :: struct {
help: bool `args:"short=h" usage:"show this documentation"`,
config_file: string `args:"name=config-file,short=c" usage:"config file" default:"~/.envr/config.json"`,
output: Output_Format `args:"short=o" usage:"the format of output data" default:"table" completion:"output"`,
color: Color_Mode `usage:"Whether or not to colorize output" default:"auto" completion:"color"`,
force: bool `args:"short=f" usage:"Overwrite existing config"`,
}
Flag_Type :: enum {
Help,
Config_File,
Output,
Color,
Force,
}
Output_Format :: enum {
Auto,
Table,
JSON,
}
Color_Mode :: enum {
Auto,
Always,
Never,
}
Positional_Arg :: struct {
name: string,
ntype: string, // nushell type: "path", "string". "" defaults to "path"
completion: string,
optional: bool,
desc: string,
} }
CommandInfo :: struct { CommandInfo :: struct {
@@ -24,34 +61,95 @@ CommandInfo :: struct {
short: string, short: string,
long: string, long: string,
aliases: []string, aliases: []string,
// Flags supported by the command
flags: bit_set[Flag_Type],
args: []Positional_Arg,
} }
GLOBAL_FLAGS: bit_set[Flag_Type] = {.Help, .Config_File, .Color}
COMMANDS := []CommandInfo { COMMANDS := []CommandInfo {
{ {
"init", name = "init",
"envr init", usage = "envr init",
"Set up envr", short = "Set up envr",
`The init command generates your initial config and saves it to long = `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
key somewhere, otherwise your data could be lost forever.`, key somewhere, otherwise your data could be lost forever.`,
{}, flags = GLOBAL_FLAGS + {.Force},
}, },
{"scan", "envr scan", "Find and select .env files for backup", "", {}},
{"sync", "envr sync", "Update or restore your env backups", "", {}},
{"backup", "envr backup <path>", "Import a .env file into envr", "", {"add"}},
{"restore", "envr restore <path>", "Restore a .env file from the database", "", {}},
{"list", "envr list", "View your tracked files", "", {}},
{"remove", "envr remove <path>", "Remove a .env file from your database", "", {}},
{"check", "envr check [path]", "Check if files are backed up", "", {}},
{"version", "envr version", "Show envr's version", "", {}},
{"edit-config", "envr edit-config", "Edit your config with your default editor", "", {}},
{ {
"nushell-completion", name = "scan",
"envr nushell-completion", usage = "envr scan",
"Generate custom completions for nushell", short = "Find and select .env files for backup",
"", flags = GLOBAL_FLAGS,
{}, },
{
name = "sync",
usage = "envr sync",
short = "Update or restore your env backups",
flags = GLOBAL_FLAGS + {.Output},
},
{
name = "backup",
usage = "envr backup <path>",
short = "Import a .env file into envr",
aliases = {"add"},
flags = GLOBAL_FLAGS,
args = {{name = "path", completion = "untracked-paths", desc = "Path to .env file to backup"}},
},
{
name = "restore",
usage = "envr restore <path>",
short = "Restore a .env file from the database",
flags = GLOBAL_FLAGS,
args = {{name = "path", completion = "tracked-paths", desc = "Path to .env file to restore"}},
},
{
name = "list",
usage = "envr list",
short = "View your tracked files",
flags = GLOBAL_FLAGS + {.Output},
},
{
name = "remove",
usage = "envr remove <path>",
short = "Remove a .env file from your database",
flags = GLOBAL_FLAGS,
args = {{name = "path", completion = "tracked-paths", desc = "Path to .env file to remove"}},
},
{
name = "check",
usage = "envr check [path]",
short = "Check if files are backed up",
flags = GLOBAL_FLAGS,
args = {{name = "path", optional = true, desc = "Path to check (defaults to current directory)"}},
},
{name = "version", usage = "envr version", short = "Show envr's version", flags = {.Help}},
{
name = "edit-config",
usage = "envr edit-config",
short = "Edit your config with your default editor",
flags = GLOBAL_FLAGS,
},
{
name = "completion",
usage = "envr completion <shell>",
short = "Generate shell completion scripts",
long = `Supported shells:
nushell
bash`,
flags = {.Help},
args = {{name = "shell", ntype = "string", completion = "shells", desc = "Shell to generate completions for"}},
},
{
name = "uninstall",
usage = "envr uninstall",
short = "Remove envr configuration and database",
long = "This will remove all files generated by envr from your system.",
flags = GLOBAL_FLAGS + {.Force},
}, },
} }
@@ -71,53 +169,33 @@ parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Comm
} }
cmd.name = args[1] cmd.name = args[1]
cmd.args = make([dynamic]string, 0, len(args[2:]))
cmd.args = make([dynamic]string) overflow := parse_flags(&cmd.flags, args[2:])
cmd.flags = make(map[string]string) for arg in overflow {
cmd.bool_set = make(map[string]bool) append(&cmd.args, arg)
// TODO: Optimize loop?
i := 2
for i < len(args) {
arg := args[i]
if strings.starts_with(arg, "--") {
key := arg[2:]
if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key] = args[i + 1]
i += 2
} else {
cmd.bool_set[key] = true
i += 1
}
} else if strings.starts_with(arg, "-") && len(arg) == 2 {
key_slice := arg[1:2]
if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key_slice] = args[i + 1]
i += 2
} else {
cmd.bool_set[key_slice] = true
i += 1
}
} else {
append(&cmd.args, arg)
i += 1
}
} }
val: string = --- if cmd.flags.output == .Auto {
if val, ok = cmd.flags["config-file"]; ok { cmd.flags.output = terminal.is_terminal(os.stdout) ? .Table : .JSON
cmd.config_path = val }
} else if val, ok = cmd.flags["c"]; ok {
cmd.config_path = val if cmd.flags.color == .Auto {
} else { cmd.flags.color = terminal.is_terminal(os.stdout) ? .Always : .Never
}
if cmd.flags.color == .Never {
disable_color = true
}
if cmd.flags.config_file == "" {
// FIXME: Handle err // FIXME: Handle err
// TODO: Is this right? // TODO: Is this right?
home, _ := os.user_home_dir(context.temp_allocator) home, _ := os.user_home_dir(context.temp_allocator)
// TODO: should we copy out of the temp_allocator? // TODO: should we copy out of the temp_allocator?
cmd.config_path = default_config_path(home, context.temp_allocator) cmd.flags.config_file = default_config_path(home, context.temp_allocator)
} }
if has_flag(&cmd, "help") { if cmd.flags.help {
print_command_help(&cmd) print_command_help(&cmd)
return cmd, false return cmd, false
} }
@@ -134,6 +212,7 @@ print_command_help :: proc(cmd: ^Command) {
} }
write_command_help :: proc(name: string, w: io.Writer) -> bool { write_command_help :: proc(name: string, w: io.Writer) -> bool {
// TODO: rename info to cmd?
info, found := find_command(name) info, found := find_command(name)
if !found { if !found {
return false return false
@@ -141,36 +220,23 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
fmt.wprintf( fmt.wprintf(
w, w,
"%s\n\n\n" + "%s\n\n\n%s\n\n %s [flags]\n\n",
COLOR_HEADINGS +
"Usage:" +
ANSI_RESET +
"\n\n " +
COLOR_FLAGS +
"%s" +
ANSI_RESET +
" [flags]\n\n",
info.short, info.short,
info.usage, colorize(.Heading, "Usage:"),
colorize(.Flag, info.usage),
flush = false, flush = false,
) )
if len(info.aliases) > 0 { if len(info.aliases) > 0 {
fmt.wprintf( fmt.wprintf(
w, w,
"\n" + "\n%s\n\n %s",
COLOR_HEADINGS + colorize(.Heading, "Aliases:"),
"Aliases:" + colorize(.Command, info.name),
ANSI_RESET +
"\n\n " +
COLOR_COMMANDS +
"%s" +
ANSI_RESET,
info.name,
flush = false, flush = false,
) )
for a in info.aliases { for a in info.aliases {
fmt.wprintf(w, ", " + COLOR_COMMANDS + "%s" + ANSI_RESET, a, flush = false) fmt.wprintf(w, ", %s", colorize(.Command, a), flush = false)
} }
fmt.wprintf(w, "\n", flush = false) fmt.wprintf(w, "\n", flush = false)
} }
@@ -179,28 +245,135 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
fmt.wprintf(w, "\n%s\n", info.long, flush = false) fmt.wprintf(w, "\n%s\n", info.long, flush = false)
} }
fmt.wprintf( tbl: table.Table
w, table.init(&tbl, context.temp_allocator, context.temp_allocator)
"\n" + table.padding(&tbl, 2, 0)
COLOR_HEADINGS + write_flags_table(&tbl, info.flags)
"Flags:" + fmt.wprintf(w, "\n", flush = false)
ANSI_RESET + write_borderless_table(w, &tbl)
"\n\n " + table_reset(&tbl)
COLOR_FLAGS +
"-h, --help" +
ANSI_RESET +
" help for %s\n " +
COLOR_FLAGS +
"-c, --config-file" +
ANSI_RESET +
` <path> config file (default "~/.envr/config.json")
`,
info.name,
flush = false,
)
return true return true
} }
Flag_Kind :: enum {
Bool,
String,
Enum,
}
Flag_Field :: struct {
long_name: string,
short_name: string,
kind: Flag_Kind,
usage: string,
default_val: string,
enum_values: string,
completion: string,
}
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,
}
}
flag_field_info :: proc(
ft: Flag_Type,
) -> (
names: string,
value_hint: string,
description: string,
) {
f := flag_field(ft)
if len(f.short_name) > 0 {
names = fmt.tprintf("-%s, --%s", f.short_name, f.long_name)
} else {
names = fmt.tprintf("--%s", f.long_name)
}
switch f.kind {
case .Bool:
value_hint = ""
case .String:
value_hint = " <value>"
case .Enum:
value_hint = fmt.tprintf(" %s", f.enum_values)
}
description = f.usage
if len(f.default_val) > 0 {
switch f.kind {
case .Bool:
// do nothing
case .String:
description = fmt.tprintf(`%s (default "%s")`, f.usage, f.default_val)
case .Enum:
description = fmt.tprintf("%s (default '%s')", f.usage, f.default_val)
}
}
return
}
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)
}
}
}
find_command :: proc(name: string) -> (CommandInfo, bool) { find_command :: proc(name: string) -> (CommandInfo, bool) {
for c in COMMANDS { for c in COMMANDS {
if c.name == name { if c.name == name {
@@ -215,11 +388,7 @@ find_command :: proc(name: string) -> (CommandInfo, bool) {
return CommandInfo{}, false return CommandInfo{}, false
} }
// TODO: command args should be shown in usage. ENVR_DESCRIPTION :: `envr keeps your .env synced to a local, encrypted database.
write_usage :: proc(w: io.Writer) {
fmt.wprintf(
w,
`envr keeps your .env synced to a local, encrypted database.
Is a safe and easy way to gather all your .env files in one place where they can Is a safe and easy way to gather all your .env files in one place where they can
easily be backed by another tool such as restic or git. easily be backed by another tool such as restic or git.
@@ -248,17 +417,16 @@ Select the files you want to back up from the interactive list.
5. If you lose a repository, after re-cloning the repo into the same path it was 5. If you lose a repository, after re-cloning the repo into the same path it was
at before, restore your backup with: at before, restore your backup with:
> envr restore ~/<path to repository>/.env > envr restore ~/<path to repository>/.env`
%sUsage:%s // TODO: command args should be shown in usage.
write_usage :: proc(w: io.Writer) {
%senvr%s [command] fmt.wprintf(
w,
`, "%s\n\n%s\n\n %s [command]\n\n",
COLOR_HEADINGS, ENVR_DESCRIPTION,
ANSI_RESET, colorize(.Heading, "Usage:"),
COLOR_FLAGS, colorize(.Flag, "envr"),
ANSI_RESET,
flush = false, flush = false,
) )
@@ -274,40 +442,27 @@ at before, restore your backup with:
for a in c.aliases { for a in c.aliases {
name = strings.join([]string{name, a}, ", ", tbl.format_allocator) name = strings.join([]string{name, a}, ", ", tbl.format_allocator)
} }
table.row(&tbl, table.format(&tbl, "%s%s%s", COLOR_COMMANDS, name, ANSI_RESET), c.short) table.row(&tbl, colorize(.Command, name, tbl.format_allocator), c.short)
} }
write_borderless_table(w, &tbl) write_borderless_table(w, &tbl)
table_reset(&tbl) table_reset(&tbl)
table.caption(&tbl, "Flags:") write_flags_table(&tbl, GLOBAL_FLAGS)
table.row(&tbl, COLOR_FLAGS + "-h, --help" + ANSI_RESET, `show this documentation`)
table.row(
&tbl,
COLOR_FLAGS + "-c, --config-file" + ANSI_RESET + " <path>",
`config file (default "~/.envr/config.json")`,
)
write_borderless_table(w, &tbl) write_borderless_table(w, &tbl)
table_reset(&tbl)
fmt.wprintf( fmt.wprintf(
w, w,
`Use "%senvr%s [command] --help" for more information about a command.`, `Use "%s [command] --help" for more information about a command.`,
COLOR_FLAGS, colorize(.Flag, "envr", tbl.format_allocator),
ANSI_RESET,
flush = false, flush = false,
) )
} }
has_flag :: proc(cmd: ^Command, name: string) -> bool {
return name in cmd.flags || name in cmd.bool_set
}
delete_command :: proc(cmd: ^Command) { delete_command :: proc(cmd: ^Command) {
bufio.writer_flush(cmd.out_buf) bufio.writer_flush(cmd.out_buf)
delete(cmd.args) delete(cmd.args)
delete(cmd.flags)
delete(cmd.bool_set)
bufio.writer_destroy(cmd.out_buf) bufio.writer_destroy(cmd.out_buf)
free(cmd.out_buf) free(cmd.out_buf)
} }
+82 -109
View File
@@ -110,7 +110,8 @@ test_command_help_init_no_aliases :: proc(t: ^testing.T) {
testing.expect(t, !strings.contains(text, "Aliases:"), "init should not have Aliases section") testing.expect(t, !strings.contains(text, "Aliases:"), "init should not have Aliases section")
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section") testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
testing.expect(t, strings.contains(text, "show this documentation"), "missing help flag description") testing.expect(t, strings.contains(text, "show this documentation"), "missing help flag description")
} testing.expect(t, strings.contains(text, "--force"), "missing --force flag")
}
@(test) @(test)
test_command_help_unknown :: proc(t: ^testing.T) { test_command_help_unknown :: proc(t: ^testing.T) {
@@ -123,7 +124,7 @@ test_command_help_unknown :: proc(t: ^testing.T) {
text := strings.to_string(b) text := strings.to_string(b)
testing.expect_value(t, len(text), 0) testing.expect_value(t, len(text), 0)
} }
@(test) @(test)
test_command_help_version :: proc(t: ^testing.T) { test_command_help_version :: proc(t: ^testing.T) {
@@ -144,53 +145,6 @@ test_command_help_version :: proc(t: ^testing.T) {
} }
test_parse_args :: proc( test_parse_args :: proc(
test_has_flag_bool_set :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
bool_set = map[string]bool{"force" = true},
}
defer delete(cmd.bool_set)
testing.expect(t, has_flag(&cmd, "force"), "should find flag in bool_set")
testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag")
}
@(test)
test_has_flag_value_map :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
flags = map[string]string{"output" = "/tmp/out"},
}
defer delete(cmd.flags)
testing.expect(t, has_flag(&cmd, "output"), "should find flag in flags map")
testing.expect(t, !has_flag(&cmd, "force"), "should not find missing flag")
}
@(test)
test_has_flag_both_maps :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
flags = map[string]string{"output" = "/tmp/out"},
bool_set = map[string]bool{"force" = true},
}
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, has_flag(&cmd, "output"), "should find in flags")
testing.expect(t, has_flag(&cmd, "force"), "should find in bool_set")
testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag")
}
@(test)
test_has_flag_empty_command :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
}
testing.expect(t, !has_flag(&cmd, "anything"), "empty command should have no flags")
}
test_parse_args :: proc(
args: []string, args: []string,
) -> ( ) -> (
cmd: Command, cmd: Command,
@@ -226,8 +180,6 @@ test_parse_args_bare_command :: proc(t: ^testing.T) {
testing.expect_value(t, cmd.name, "list") testing.expect_value(t, cmd.name, "list")
testing.expect_value(t, len(cmd.args), 0) testing.expect_value(t, len(cmd.args), 0)
} }
testing.expect_value(t, len(cmd.bool_set), 0)
}
@(test) @(test)
test_parse_args_positional :: proc(t: ^testing.T) { test_parse_args_positional :: proc(t: ^testing.T) {
@@ -236,49 +188,51 @@ test_parse_args_positional :: proc(t: ^testing.T) {
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
testing.expect_value(t, cmd.name, "backup") testing.expect_value(t, cmd.name, "backup")
testing.expect(t, len(cmd.args) == 1) testing.expect_value(t, len(cmd.args), 1)
testing.expect(t, cmd.args[0] == "/project/.env") testing.expect_value(t, cmd.args[0], "/project/.env")
} }
@(test) @(test)
test_parse_args_config_file_long_flag :: proc(t: ^testing.T) { test_parse_args_config_file_long_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "--config", "x.json"}) cmd, ok, _, _ := test_parse_args(
testing.expect(t, ok, "should succeed") []string{"envr", "sync", "--config-file", "x.json"},
)
testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.config_file, "x.json") testing.expect_value(t, cmd.flags.config_file, "x.json")
} }
@(test) @(test)
test_parse_args_config_file_short_flag :: proc(t: ^testing.T) { test_parse_args_config_file_short_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "-c", "x.json"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "-c", "x.json"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.config_file, "x.json") testing.expect_value(t, cmd.flags.config_file, "x.json")
} }
@(test) @(test)
test_parse_args_force_long_flag :: proc(t: ^testing.T) { test_parse_args_force_long_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "--force"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "--force"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.force, true) testing.expect_value(t, cmd.flags.force, true)
} }
@(test) @(test)
test_parse_args_force_short_flag :: proc(t: ^testing.T) { test_parse_args_force_short_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "version", "-l"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "-f"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.force, true) testing.expect_value(t, cmd.flags.force, true)
} }
@(test) @(test)
test_parse_args_multiple_positionals :: proc(t: ^testing.T) { test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
@@ -288,9 +242,9 @@ test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, len(cmd.args), 2) testing.expect_value(t, len(cmd.args), 2)
testing.expect(t, cmd.args[0] == "a") testing.expect_value(t, cmd.args[0], "a")
testing.expect(t, cmd.args[1] == "b") testing.expect_value(t, cmd.args[1], "b")
} }
@(test) @(test)
test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) { test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
@@ -300,9 +254,9 @@ test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.force, true) testing.expect_value(t, cmd.flags.force, true)
testing.expect(t, len(cmd.args) == 1) testing.expect_value(t, len(cmd.args), 1)
testing.expect(t, cmd.args[0] == "/project/.env") testing.expect_value(t, cmd.args[0], "/project/.env")
} }
@(test) @(test)
test_parse_args_no_args :: proc(t: ^testing.T) { test_parse_args_no_args :: proc(t: ^testing.T) {
@@ -314,58 +268,77 @@ test_parse_args_no_args :: proc(t: ^testing.T) {
@(test) @(test)
test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) { test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "--force", "a.env", "--output", "json"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "--force", "a.env", "--output", "json"})
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
testing.expect_value(t, cmd.flags.force, true) testing.expect_value(t, cmd.flags.force, true)
testing.expect(t, cmd.bool_set["verbose"] == true) testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
testing.expect(t, len(cmd.args) == 1) testing.expect_value(t, len(cmd.args), 1)
testing.expect(t, cmd.args[0] == "a.env") testing.expect_value(t, cmd.args[0], "a.env")
} }
@(test) @(test)
test_parse_args_config_file_default :: proc(t: ^testing.T) { test_parse_args_config_file_default :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args( cmd, ok, _, _ := test_parse_args([]string{"envr", "list"})
[]string{"envr", "list", "--config-file", "/custom/config.json"},
)
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(
t,
cmd.config_path == "/custom/config.json",
"config_path should be set from --config-file",
)
}
@(test)
test_parse_args_config_file_short_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-c", "/custom/config.json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(
t,
cmd.config_path == "/custom/config.json",
"config_path should be set from -c",
)
}
@(test)
test_parse_args_config_file_defaults :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect(t, len(cmd.flags.config_file) > 0, "config_file should default to non-empty path") testing.expect(t, len(cmd.flags.config_file) > 0, "config_file should default to non-empty path")
testing.expect( testing.expect(
t, t,
strings.contains(cmd.flags.config_file, ".envr"), strings.contains(cmd.flags.config_file, ".envr"),
"default config_path should contain .envr dir, got %s", "default config_file should contain .envr dir, got %s",
) )
} }
@(test) @(test)
test_parse_args_output_long_json :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--output", "json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
}
@(test)
test_parse_args_output_short_json :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-o", "json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
}
@(test)
test_parse_args_output_long_table :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--output", "table"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.Table)
}
@(test)
test_parse_args_output_short_table :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-o", "table"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.Table)
}
@(test)
test_parse_args_output_equals_syntax :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--output=json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ cmd_backup :: proc(cmd: ^Command) {
return return
} }
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.flags.config_file)
if !db_ok { if !db_ok {
return return
} }
+1 -1
View File
@@ -24,7 +24,7 @@ cmd_check :: proc(cmd: ^Command) {
return return
} }
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.flags.config_file)
if !db_ok { if !db_ok {
return return
} }
+5 -10
View File
@@ -1,7 +1,6 @@
#+test #+test
package main package main
import "core:fmt"
import "core:testing" import "core:testing"
@(test) @(test)
@@ -10,13 +9,9 @@ test_find_unbacked_finds_missing :: proc(t: ^testing.T) {
db := []EnvFile{{path = "/a/.env"}, {path = "/b/.env"}} db := []EnvFile{{path = "/a/.env"}, {path = "/b/.env"}}
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 1, fmt.tprintf("expected 1 unbacked, got %d", len(result))) testing.expect_value(t, len(result), 1)
if len(result) > 0 { if len(result) > 0 {
testing.expect( testing.expect_value(t, result[0], "/c/.env")
t,
result[0] == "/c/.env",
fmt.tprintf("expected /c/.env, got %s", result[0]),
)
} }
} }
@@ -26,7 +21,7 @@ test_find_unbacked_all_backed :: proc(t: ^testing.T) {
db := []EnvFile{{path = "/a/.env"}, {path = "/b/.env"}} db := []EnvFile{{path = "/a/.env"}, {path = "/b/.env"}}
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result))) testing.expect_value(t, len(result), 0)
} }
@(test) @(test)
@@ -35,7 +30,7 @@ test_find_unbacked_no_local :: proc(t: ^testing.T) {
db := []EnvFile{{path = "/a/.env"}} db := []EnvFile{{path = "/a/.env"}}
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result))) testing.expect_value(t, len(result), 0)
} }
@(test) @(test)
@@ -44,6 +39,6 @@ test_find_unbacked_none_backed :: proc(t: ^testing.T) {
db: []EnvFile db: []EnvFile
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 2, fmt.tprintf("expected 2 unbacked, got %d", len(result))) testing.expect_value(t, len(result), 2)
} }
+268
View File
@@ -0,0 +1,268 @@
package main
import "core:fmt"
import "core:strings"
nushell_header :: `def tracked-paths [] {
(
^envr list
| from json
| each {
[$in.directory $in.path] | path join
}
)
}
def untracked-paths [] {
(
^envr scan
| from json
)
}
def color [] {
['auto' 'always' 'never']
}
def output [] {
['auto' 'table' 'json']
}
def shells [] {
['nushell' 'bash']
}
`
cmd_completion :: proc(cmd: ^Command) {
if len(cmd.args) == 0 {
print_command_help(cmd)
return
}
switch cmd.args[0] {
case "nushell":
fmt.wprint(cmd.out, generate_nushell_completion(), flush = true)
case "bash":
fmt.wprint(cmd.out, generate_bash_completion(), flush = true)
case:
fmt.wprintf(cmd.err, "Unsupported shell: %s\n", cmd.args[0])
fmt.wprintln(cmd.err, "Supported shells: nushell, bash")
}
}
generate_nushell_completion :: proc() -> string {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
fmt.sbprint(&sb, nushell_header)
for c in COMMANDS {
fmt.sbprintf(&sb, "# %s\n", c.short)
fmt.sbprintf(&sb, "export extern \"envr %s\" [\n", c.name)
for ft in Flag_Type {
if ft not_in c.flags do continue
f := flag_field(ft)
fmt.sbprintf(&sb, " %s\n", nushell_flag_line(f))
}
for arg in c.args {
fmt.sbprintf(&sb, " %s\n", nushell_positional_line(arg))
}
fmt.sbprintf(&sb, "]\n")
for a in c.aliases {
fmt.sbprintf(&sb, "\nexport alias \"envr %s\" = envr %s\n", a, c.name)
}
fmt.sbprintf(&sb, "\n")
}
return strings.to_string(sb)
}
nushell_flag_line :: proc(f: Flag_Field) -> string {
line: string
if len(f.short_name) > 0 {
line = fmt.tprintf("--%s(-%s)", f.long_name, f.short_name)
} else {
line = fmt.tprintf("--%s", f.long_name)
}
switch f.kind {
case .Bool:
case .String:
line = fmt.tprintf("%s: path", line)
case .Enum:
if len(f.completion) > 0 {
line = fmt.tprintf("%s: string@%s", line, f.completion)
} else {
line = fmt.tprintf("%s: string", line)
}
}
return fmt.tprintf("%s # %s", line, f.usage)
}
nushell_positional_line :: proc(arg: Positional_Arg) -> string {
name := arg.name
if arg.optional {
name = fmt.tprintf("%s?", name)
}
ntype := len(arg.ntype) > 0 ? arg.ntype : "path"
if len(arg.completion) > 0 {
return fmt.tprintf("%s: %s@%s", name, ntype, arg.completion)
}
return fmt.tprintf("%s: %s", name, ntype)
}
bash_header :: `
_envr() {
local cur prev cmd
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
cmd="${COMP_WORDS[1]}"
`
bash_footer :: `
}
complete -F _envr envr
`
generate_bash_completion :: proc() -> string {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
fmt.sbprint(&sb, bash_header)
// B: Subcommand completion
fmt.sbprintf(&sb, " if [[ $COMP_CWORD -eq 1 ]]; then\n")
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"")
first := true
for c in COMMANDS {
if !first do fmt.sbprintf(&sb, " ")
fmt.sbprintf(&sb, "%s", c.name)
first = false
for a in c.aliases {
fmt.sbprintf(&sb, " %s", a)
}
}
fmt.sbprintf(&sb, "\" -- \"$cur\") )\n")
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " fi\n\n")
// C: Flag value completion
fmt.sbprintf(&sb, " case \"$prev\" in\n")
for ft in Flag_Type {
f := flag_field(ft)
if f.kind == .Bool do continue
pattern := fmt.tprintf("--%s", f.long_name)
if len(f.short_name) > 0 {
pattern = fmt.tprintf("%s|-%s", pattern, f.short_name)
}
fmt.sbprintf(&sb, " %s)\n", pattern)
switch f.kind {
case .Enum:
fmt.sbprintf(
&sb,
" COMPREPLY=( $(compgen -W \"%s\" -- \"$cur\") )\n",
bash_enum_values(f.enum_values),
)
case .String:
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -f -- \"$cur\") )\n")
case .Bool:
panic("unexpected")
}
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " ;;\n")
}
fmt.sbprintf(&sb, " esac\n\n")
// D: Flag name completion per command
fmt.sbprintf(&sb, " case \"$cur\" in -*)\n")
fmt.sbprintf(&sb, " case \"$cmd\" in\n")
for c in COMMANDS {
if c.flags == {} do continue
cmd_pattern := c.name
for a in c.aliases {
cmd_pattern = fmt.tprintf("%s|%s", cmd_pattern, a)
}
fmt.sbprintf(&sb, " %s)\n", cmd_pattern)
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"")
flag_first := true
for ft in Flag_Type {
if ft not_in c.flags do continue
f := flag_field(ft)
if !flag_first do fmt.sbprintf(&sb, " ")
fmt.sbprintf(&sb, "--%s", f.long_name)
if len(f.short_name) > 0 {
fmt.sbprintf(&sb, " -%s", f.short_name)
}
flag_first = false
}
fmt.sbprintf(&sb, "\" -- \"$cur\") )\n")
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " ;;\n")
}
fmt.sbprintf(&sb, " esac\n")
fmt.sbprintf(&sb, " ;;\n")
fmt.sbprintf(&sb, " esac\n\n")
// E: Positional completion
fmt.sbprintf(&sb, " case \"$cmd\" in\n")
for c in COMMANDS {
has_comp := false
for arg in c.args {
if len(arg.completion) > 0 {
has_comp = true
break
}
}
if !has_comp do continue
cmd_pattern := c.name
for a in c.aliases {
cmd_pattern = fmt.tprintf("%s|%s", cmd_pattern, a)
}
fmt.sbprintf(&sb, " %s)\n", cmd_pattern)
for arg in c.args {
if len(arg.completion) == 0 do continue
comp: string
switch arg.completion {
case "tracked-paths":
comp = "$(envr list --output json 2>/dev/null)"
case "untracked-paths":
comp = "$(envr scan --output json 2>/dev/null)"
case "shells":
comp = "nushell bash"
case:
continue
}
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"%s\" -- \"$cur\") )\n", comp)
}
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " ;;\n")
}
fmt.sbprintf(&sb, " esac\n")
fmt.sbprint(&sb, bash_footer)
return strings.to_string(sb)
}
bash_enum_values :: proc(enum_values: string) -> string {
s, _ := strings.replace(enum_values, "'", "", -1, context.temp_allocator)
s, _ = strings.replace(s, "|", " ", -1, context.temp_allocator)
return s
}
+133
View File
@@ -0,0 +1,133 @@
#+test
package main
import "core:fmt"
import "core:strings"
import "core:testing"
@(test)
test_nushell_completion_nonempty :: proc(t: ^testing.T) {
script := generate_nushell_completion()
testing.expect(t, len(script) > 0, "completion script should not be empty")
}
@(test)
test_nushell_completion_contains_commands :: proc(t: ^testing.T) {
script := generate_nushell_completion()
expected := []string{
"tracked-paths",
"untracked-paths",
"envr backup",
"envr check",
"envr edit-config",
"envr init",
"envr list",
"envr remove",
"envr restore",
"envr scan",
"envr sync",
"envr version",
"envr completion",
}
for ext in expected {
testing.expect(
t,
strings.contains(script, ext),
fmt.tprintf("expected script to contain %q", ext),
)
}
}
@(test)
test_nushell_completion_contains_flags :: proc(t: ^testing.T) {
script := generate_nushell_completion()
expected_flags := []string{
"--help(-h)",
"--config-file(-c)",
"--color",
"--force(-f)",
"--output(-o)",
}
for flag in expected_flags {
testing.expect(
t,
strings.contains(script, flag),
fmt.tprintf("expected script to contain %q", flag),
)
}
}
@(test)
test_nushell_completion_contains_aliases :: proc(t: ^testing.T) {
script := generate_nushell_completion()
testing.expect(
t,
strings.contains(script, "envr add"),
"expected script to contain 'envr add' alias",
)
}
@(test)
test_nushell_completion_no_help_command :: proc(t: ^testing.T) {
script := generate_nushell_completion()
testing.expect(
t,
!strings.contains(script, "envr help"),
"script should not contain 'envr help' (not a real command)",
)
}
@(test)
test_bash_completion_nonempty :: proc(t: ^testing.T) {
script := generate_bash_completion()
testing.expect(t, len(script) > 0, "bash completion script should not be empty")
}
@(test)
test_bash_completion_contains_registration :: proc(t: ^testing.T) {
script := generate_bash_completion()
testing.expect(
t,
strings.contains(script, "complete -F _envr envr"),
"expected bash script to register completion function",
)
}
@(test)
test_bash_completion_contains_commands :: proc(t: ^testing.T) {
script := generate_bash_completion()
expected := []string{
"init", "scan", "sync", "backup", "add",
"restore", "list", "remove", "check",
"version", "edit-config", "completion",
}
for cmd in expected {
testing.expect(
t,
strings.contains(script, cmd),
fmt.tprintf("expected bash script to contain %q", cmd),
)
}
}
@(test)
test_bash_completion_contains_flags :: proc(t: ^testing.T) {
script := generate_bash_completion()
expected := []string{
"--help", "--config-file", "--color", "--force", "--output",
}
for flag in expected {
testing.expect(
t,
strings.contains(script, flag),
fmt.tprintf("expected bash script to contain %q", flag),
)
}
}
@(test)
test_bash_completion_contains_enum_values :: proc(t: ^testing.T) {
script := generate_bash_completion()
testing.expect(t, strings.contains(script, "auto table json"), "missing output enum values")
testing.expect(t, strings.contains(script, "auto always never"), "missing color enum values")
}
+1 -1
View File
@@ -10,7 +10,7 @@ cmd_edit_config :: proc(cmd: ^Command) {
return return
} }
config_path := cmd.config_path config_path := cmd.flags.config_file
if !os.exists(config_path) { if !os.exists(config_path) {
fmt.wprintf( fmt.wprintf(
+13 -7
View File
@@ -4,11 +4,12 @@ import "core:fmt"
import "core:terminal/ansi" import "core:terminal/ansi"
cmd_init :: proc(cmd: ^Command) { cmd_init :: proc(cmd: ^Command) {
force := has_flag(cmd, "force") || has_flag(cmd, "f") force := cmd.flags.force
config_file := cmd.flags.config_file
fmt.wprintln(cmd.out, cmd.config_path, flush = false) fmt.wprintln(cmd.out, cmd.flags.config_file, flush = false)
_, cfg_exists := load_config(cmd.config_path) _, cfg_exists := load_config(config_file)
if cfg_exists && !force { if cfg_exists && !force {
fmt.wprintln( fmt.wprintln(
cmd.out, cmd.out,
@@ -25,15 +26,19 @@ Run again with the --force flag if you want to reinitialize.`,
} }
if len(keys) == 0 { if len(keys) == 0 {
fmt.wprintln(cmd.err, `No ssh-ed25519 keys found in ~/.ssh fmt.wprintln(
Generate one with: ssh-keygen -t ed25519`, flush = false) cmd.err,
`No ssh-ed25519 keys found in ~/.ssh
Generate one with: ssh-keygen -t ed25519`,
flush = false,
)
return return
} }
selected, result := multi_select("Select SSH private keys:", keys[:]) selected, result := multi_select("Select SSH private keys:", keys[:])
defer delete(selected) defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.wprintln(cmd.out, ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET, flush = false) fmt.wprintln(cmd.out, colorize(.Message, "Cancelled."), flush = false)
return return
} }
@@ -49,7 +54,7 @@ Generate one with: ssh-keygen -t ed25519`, flush = false)
return return
} }
cfg := new_config(selected_paths[:], cmd.config_path) cfg := new_config(selected_paths[:], config_file)
if !save_config(cfg, force = force) { if !save_config(cfg, force = force) {
return return
} }
@@ -61,3 +66,4 @@ Generate one with: ssh-keygen -t ed25519`, flush = false)
flush = false, flush = false,
) )
} }
+10 -8
View File
@@ -5,7 +5,6 @@ import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
import "core:terminal"
import "core:text/table" import "core:text/table"
ListEntry :: struct { ListEntry :: struct {
@@ -13,10 +12,9 @@ ListEntry :: struct {
path: string `json:"path"`, path: string `json:"path"`,
} }
// TODO: Support --format flag
// TODO: Improve table rendering // TODO: Improve table rendering
cmd_list :: proc(cmd: ^Command) { cmd_list :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.flags.config_file)
if !db_ok { if !db_ok {
return return
} }
@@ -27,15 +25,15 @@ cmd_list :: proc(cmd: ^Command) {
return return
} }
if terminal.is_terminal(os.stdout) { if cmd.flags.output == .Table {
t: table.Table t: table.Table
table.init(&t, context.temp_allocator, context.temp_allocator) table.init(&t, context.temp_allocator, context.temp_allocator)
table.padding(&t, 1, 1) table.padding(&t, 1, 1)
table.aligned_header_of_values( table.aligned_header_of_values(
&t, &t,
.Center, .Center,
COLOR_TABLE_HEADING + "Directory" + ANSI_RESET, colorize(.Table_Heading, "Directory"),
COLOR_TABLE_HEADING + "Path" + ANSI_RESET, colorize(.Table_Heading, "Path"),
) )
for row in rows { for row in rows {
@@ -51,18 +49,22 @@ cmd_list :: proc(cmd: ^Command) {
table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width) table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width)
} else { } else {
// TODO: Should we instead print full entries here? // TODO: Should we instead print full entries here?
entries: [dynamic]ListEntry entries := make([dynamic]ListEntry, 0, len(rows), context.temp_allocator)
for row in rows { for row in rows {
filename := filepath.base(row.path) filename := filepath.base(row.path)
append( append(
&entries, &entries,
ListEntry { ListEntry {
dir = strings.concatenate({row.dir, "/"}, context.temp_allocator), dir = strings.concatenate(
{row.dir, os.Path_Separator_String},
context.temp_allocator,
),
path = filename, path = filename,
}, },
) )
} }
data, marshal_err := json.marshal(entries[:], allocator = context.temp_allocator) data, marshal_err := json.marshal(entries[:], allocator = context.temp_allocator)
if marshal_err != nil { if marshal_err != nil {
fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false) fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
+98 -2
View File
@@ -1,7 +1,11 @@
#+feature dynamic-literals
#+test #+test
package main package main
import "core:bufio"
import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings"
import "core:testing" import "core:testing"
@(test) @(test)
@@ -11,9 +15,101 @@ test_filepath_base_equals_rel :: proc(t: ^testing.T) {
for path in cases { for path in cases {
dir := filepath.dir(path) dir := filepath.dir(path)
rel, rel_err := filepath.rel(dir, path, context.temp_allocator) rel, rel_err := filepath.rel(dir, path, context.temp_allocator)
testing.expect(t, rel_err == nil, "filepath.rel returned an error") testing.expect_value(t, rel_err, nil)
base := filepath.base(path) base := filepath.base(path)
testing.expect(t, rel == base, "filepath.rel(dir, path) should equal filepath.base(path)") testing.expect_value(t, rel, base)
} }
} }
@(test)
test_cmd_list_output_json :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-list-json-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join([]string{base, "config.json"}, context.temp_allocator)
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "list", "--output", "json", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_list(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "["), "json output should contain '['")
testing.expect(
t,
strings.contains(output, "\"directory\""),
"json output should contain directory key",
)
}
@(test)
test_cmd_list_output_table :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-list-table-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join([]string{base, "config.json"}, context.temp_allocator)
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "list", "--output", "table", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_list(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "│"), "table output should contain border chars")
testing.expect(
t,
strings.contains(output, "Directory"),
"table output should contain Directory header",
)
}
-10
View File
@@ -1,10 +0,0 @@
package main
import "core:fmt"
COMPLETION_SCRIPT: string : string(#load("mod.nu"))
cmd_nushell_completion :: proc(cmd: ^Command) {
fmt.wprint(cmd.out, COMPLETION_SCRIPT, flush = false)
}
-37
View File
@@ -1,37 +0,0 @@
#+test
package main
import "core:fmt"
import "core:strings"
import "core:testing"
@(test)
test_nushell_completion_nonempty :: proc(t: ^testing.T) {
testing.expect(t, len(COMPLETION_SCRIPT) > 0, "completion script should not be empty")
}
@(test)
test_nushell_completion_contains_externs :: proc(t: ^testing.T) {
expected := []string{
"tracked-paths",
"untracked-paths",
"envr backup",
"envr check",
"envr edit-config",
"envr help",
"envr init",
"envr list",
"envr remove",
"envr restore",
"envr scan",
"envr sync",
"envr nushell-completion",
}
for ext in expected {
testing.expect(
t,
strings.contains(COMPLETION_SCRIPT, ext),
fmt.tprintf("expected script to contain %q", ext),
)
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ cmd_remove :: proc(cmd: ^Command) {
return return
} }
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.flags.config_file)
if !db_ok { if !db_ok {
return return
} }
+1 -1
View File
@@ -23,7 +23,7 @@ cmd_restore :: proc(cmd: ^Command) {
return return
} }
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.flags.config_file)
if !db_ok { if !db_ok {
return return
} }
+8 -22
View File
@@ -4,10 +4,9 @@ import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:terminal" import "core:terminal"
import "core:terminal/ansi"
cmd_scan :: proc(cmd: ^Command) { cmd_scan :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.flags.config_file)
if !db_ok { if !db_ok {
return return
} }
@@ -72,11 +71,7 @@ cmd_scan :: proc(cmd: ^Command) {
selected, result := multi_select("Select .env files to backup:", files[:]) selected, result := multi_select("Select .env files to backup:", files[:])
defer delete(selected) defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.wprintln( fmt.wprintln(cmd.out, colorize(.Message, "Cancelled."), flush = false)
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET,
flush = false,
)
return return
} }
@@ -100,25 +95,16 @@ cmd_scan :: proc(cmd: ^Command) {
} }
if added_count > 0 { if added_count > 0 {
fmt.wprintf( fmt.wprintln(
cmd.out, cmd.out,
ansi.CSI + colorize(
ansi.BOLD + .Sucess,
";" + fmt.tprintf("Successfully added %d file(s) to backup.", added_count),
ansi.FG_GREEN + ),
ansi.SGR +
"Successfully added %d file(s) to backup." +
ANSI_RESET +
"\n",
added_count,
flush = false, flush = false,
) )
} else { } else {
fmt.wprintln( fmt.wprintln(cmd.out, colorize(.Message, "No files were added."), flush = false)
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "No files were added." + ANSI_RESET,
flush = false,
)
} }
} }
+4 -7
View File
@@ -2,8 +2,6 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:os"
import "core:terminal"
import "core:text/table" import "core:text/table"
SyncEntry :: struct { SyncEntry :: struct {
@@ -12,9 +10,8 @@ SyncEntry :: struct {
} }
// TODO: Check for quiet failures. // TODO: Check for quiet failures.
// TODO: Support --format -f flags
cmd_sync :: proc(cmd: ^Command) { cmd_sync :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.config_path) db, db_ok := db_open(cmd.flags.config_file)
if !db_ok { if !db_ok {
return return
} }
@@ -49,7 +46,7 @@ cmd_sync :: proc(cmd: ^Command) {
} }
} }
if terminal.is_terminal(os.stdout) { if cmd.flags.output == .Table {
t: table.Table t: table.Table
table.init(&t, context.temp_allocator, context.temp_allocator) table.init(&t, context.temp_allocator, context.temp_allocator)
table.padding(&t, 1, 1) table.padding(&t, 1, 1)
@@ -57,8 +54,8 @@ cmd_sync :: proc(cmd: ^Command) {
table.aligned_header_of_values( table.aligned_header_of_values(
&t, &t,
.Center, .Center,
COLOR_TABLE_HEADING + "File" + ANSI_RESET, colorize(.Table_Heading, "File"),
COLOR_TABLE_HEADING + "Status" + ANSI_RESET, colorize(.Table_Heading, "Status"),
) )
for res in results { for res in results {
+64
View File
@@ -0,0 +1,64 @@
package main
import "core:bufio"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:terminal"
cmd_uninstall :: proc(cmd: ^Command) {
envr_d := envr_dir(cmd.flags.config_file)
if !os.exists(envr_d) {
fmt.wprintln(cmd.out, "Nothing to remove.", flush = false)
return
}
entries, err := os.read_all_directory_by_path(envr_d, context.temp_allocator)
if err != nil {
fmt.wprintf(cmd.err, "Error reading %s: %v\n", envr_d, err, flush = false)
return
}
paths := make([]string, len(entries), context.temp_allocator)
for entry, i in entries {
paths[i], _ = filepath.join([]string{envr_d, entry.name}, context.temp_allocator)
}
fmt.wprintln(cmd.out, "This will remove:", flush = false)
for path in paths {
fmt.wprintf(cmd.out, " %s\n", path, flush = false)
}
fmt.wprintf(cmd.out, " %s\n", envr_d, flush = false)
if !cmd.flags.force {
if !terminal.is_terminal(os.stdin) {
fmt.wprintln(cmd.err, "Run with --force to skip confirmation.", flush = false)
return
}
fmt.wprintf(cmd.out, "\nAre you sure? [y/N] ", flush = false)
bufio.writer_flush(cmd.out_buf)
buf: [128]u8
n, _ := os.read(os.stdin, buf[:])
response := strings.trim_space(string(buf[:n]))
if response != "y" && response != "Y" {
fmt.wprintln(cmd.out, "Aborted.", flush = false)
return
}
}
remove_err := os.remove_all(envr_d)
if remove_err != nil {
fmt.wprintf(cmd.err, "Error removing %s: %v\n", envr_d, remove_err, flush = false)
return
}
for path in paths {
fmt.wprintf(cmd.out, "Removed %s\n", path, flush = false)
}
fmt.wprintf(cmd.out, "Removed %s\n", envr_d, flush = false)
}
+97
View File
@@ -0,0 +1,97 @@
#+test
package main
import "core:bufio"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:testing"
@(test)
test_cmd_uninstall_force :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-uninstall-*")
cfg_path, _ := filepath.join([]string{base, ".envr", "config.json"}, context.temp_allocator)
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
defer delete(f.remotes)
db_insert(&db, f)
db_close(&db)
envr_d := filepath.dir(cfg_path)
testing.expect(t, os.exists(envr_d), ".envr directory should exist before uninstall")
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "uninstall", "--force", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_uninstall(&cmd)
bufio.writer_flush(cmd.out_buf)
testing.expect(t, !os.exists(envr_d), ".envr directory should be removed")
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "Removed"), "output should list removed files")
testing.expect(
t,
strings.contains(output, "config.json"),
"output should mention config.json",
)
testing.expect(
t,
strings.contains(output, "data.envr"),
"output should mention data.envr",
)
}
@(test)
test_cmd_uninstall_nothing_to_remove :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-uninstall-empty-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join([]string{base, ".envr", "config.json"}, context.temp_allocator)
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "uninstall", "--force", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_uninstall(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
testing.expect(
t,
strings.contains(output, "Nothing to remove"),
"output should say nothing to remove",
)
}
+58 -9
View File
@@ -1,17 +1,66 @@
package main package main
import "base:runtime"
import "core:strings"
import "core:terminal/ansi" import "core:terminal/ansi"
COLOR_HEADINGS :: Color_Code :: enum {
ansi.CSI + ansi.FG_BRIGHT_GREEN + ";" + ansi.BOLD + ";" + ansi.UNDERLINE + ansi.SGR Heading,
Command,
// Example
Flag,
Table_Heading,
Message,
Sucess,
Caret,
Option_Label,
}
COLOR_COMMANDS :: ansi.CSI + ansi.FG_BRIGHT_CYAN + ";" + ansi.BOLD + ansi.SGR // COLOR_EXAMPLE :: ansi.CSI + ansi.ITALIC + ansi.SGR
COLOR_EXAMPLE :: ansi.CSI + ansi.ITALIC + ansi.SGR
COLOR_FLAGS :: ansi.CSI + ansi.BOLD + ";" + ansi.FG_BRIGHT_WHITE + ansi.SGR
COLOR_TABLE_HEADING :: ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR
@(private = "file")
ANSI_RESET :: ansi.CSI + ansi.RESET + ansi.SGR ANSI_RESET :: ansi.CSI + ansi.RESET + ansi.SGR
disable_color := false
colorize :: proc(
color: Color_Code,
text: string,
allocator := context.temp_allocator,
disable := disable_color,
) -> (
string,
runtime.Allocator_Error,
) #optional_allocator_error {
if disable {
return text, nil
} else {
return strings.concatenate(
{ansi.CSI, color_code(color), ansi.SGR, text, ANSI_RESET},
allocator,
)
}
}
@(private = "file")
color_code :: proc(code: Color_Code) -> string {
switch code {
case .Heading:
return ansi.BOLD + ";" + ansi.UNDERLINE + ";" + ansi.FG_BRIGHT_GREEN
case .Command:
return ansi.BOLD + ";" + ansi.FG_BRIGHT_CYAN
case .Flag:
return ansi.BOLD + ";" + ansi.FG_BRIGHT_WHITE
case .Table_Heading:
return ansi.FG_BRIGHT_GREEN
case .Message:
return ansi.FAINT
case .Sucess, .Caret:
return ansi.BOLD + ";" + ansi.FG_GREEN
case .Option_Label:
return ansi.BOLD + ";" + ansi.FG_CYAN
case:
panic("Unknown case")
}
}
+14 -13
View File
@@ -30,14 +30,14 @@ load_config :: proc(config_path: string, allocator := context.allocator) -> (Con
// TODO: Should we use context.allocator + defer delete()? // TODO: Should we use context.allocator + defer delete()?
data, read_err := os.read_entire_file_from_path(config_path, context.temp_allocator) data, read_err := os.read_entire_file_from_path(config_path, context.temp_allocator)
if read_err != nil { if read_err != nil {
fmt.println("No config file found. Please run `envr init` to generate one.") fmt.eprintln("No config file found. Please run `envr init` to generate one.")
return Config{}, false return Config{}, false
} }
cfg: Config cfg: Config
err := json.unmarshal(data, &cfg, .JSON5, allocator) err := json.unmarshal(data, &cfg, .JSON5, allocator)
if err != nil { if err != nil {
fmt.printf("Error parsing config: %v\n", err) fmt.eprintf("Error parsing config: %v\n", err)
return Config{}, false return Config{}, false
} }
cfg.config_path = config_path cfg.config_path = config_path
@@ -79,7 +79,7 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
if !os.exists(config_dir) { if !os.exists(config_dir) {
mkdir_err := os.make_directory(config_dir) mkdir_err := os.make_directory(config_dir)
if mkdir_err != nil { if mkdir_err != nil {
fmt.printf("Error creating %s directory: %v\n", config_dir, mkdir_err) fmt.eprintf("Error creating %s directory: %v\n", config_dir, mkdir_err)
return false return false
} }
} }
@@ -89,7 +89,7 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
if stat_err == nil { if stat_err == nil {
defer os.file_info_delete(info, context.temp_allocator) defer os.file_info_delete(info, context.temp_allocator)
if info.size > 0 { if info.size > 0 {
fmt.println("Config file already exists. Run again with --force to reinitialize.") fmt.eprintln("Config file already exists. Run again with --force to reinitialize.")
return false return false
} }
} }
@@ -101,13 +101,13 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
context.temp_allocator, context.temp_allocator,
) )
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling config: %v\n", marshal_err) fmt.eprintf("Error marshaling config: %v\n", marshal_err)
return false return false
} }
write_err := os.write_entire_file(cfg.config_path, data) write_err := os.write_entire_file(cfg.config_path, data)
if write_err != nil { if write_err != nil {
fmt.printf("Error writing config: %v\n", write_err) fmt.eprintf("Error writing config: %v\n", write_err)
return false return false
} }
@@ -123,7 +123,7 @@ new_config :: proc(
for priv in private_key_paths { for priv in private_key_paths {
// TODO: Is this bad? // TODO: Is this bad?
priv_key := strings.clone(priv) priv_key := strings.clone(priv)
pub, _ := strings.concatenate([]string{priv_key, ".pub"}) pub := strings.concatenate([]string{priv_key, ".pub"})
append(&keys, SshKeyPair{private = priv_key, public = pub}) append(&keys, SshKeyPair{private = priv_key, public = pub})
} }
@@ -150,19 +150,19 @@ new_config :: proc(
find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) { find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
home, home_err := os.user_home_dir(context.allocator) home, home_err := os.user_home_dir(context.allocator)
if home_err != nil { if home_err != nil {
fmt.printf("Error getting home dir: %v\n", home_err) fmt.eprintf("Error getting home dir: %v\n", home_err)
return return
} }
ssh_dir, join_err := filepath.join([]string{home, ".ssh"}) ssh_dir, join_err := filepath.join([]string{home, ".ssh"})
if join_err != nil { if join_err != nil {
fmt.printf("Error building ssh path: %v\n", join_err) fmt.eprintf("Error building ssh path: %v\n", join_err)
return return
} }
entries, dir_err := os.read_all_directory_by_path(ssh_dir, context.allocator) entries, dir_err := os.read_all_directory_by_path(ssh_dir, context.allocator)
if dir_err != nil { if dir_err != nil {
fmt.printf("Could not read ~/.ssh directory: %v\n", dir_err) fmt.eprintf("Could not read ~/.ssh directory: %v\n", dir_err)
return return
} }
defer os.file_info_slice_delete(entries, context.allocator) defer os.file_info_slice_delete(entries, context.allocator)
@@ -201,8 +201,7 @@ find_git_roots :: proc(
ok: bool, ok: bool,
) { ) {
paths := search_paths(cfg, allocator) paths := search_paths(cfg, allocator)
// TODO: Pass allocator to findr findr.find_repos(paths[:], &roots, os.get_processor_core_count(), allocator)
findr.find_repos(paths[:], &roots, os.get_processor_core_count())
ok = true ok = true
return return
} }
@@ -214,6 +213,7 @@ search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]st
} }
paths := new_clone(cfg.scan_config.include, allocator) paths := new_clone(cfg.scan_config.include, allocator)
defer free(paths, allocator)
for &include in paths { for &include in paths {
expanded, _ := strings.replace(include, "~", home, 1, allocator) expanded, _ := strings.replace(include, "~", home, 1, allocator)
@@ -227,7 +227,8 @@ search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]st
} }
} }
} }
return paths^ result := paths^
return result
} }
envr_dir :: proc(config_path: string) -> string { envr_dir :: proc(config_path: string) -> string {
+37 -33
View File
@@ -2,6 +2,7 @@
package main package main
import "core:fmt" import "core:fmt"
import "core:mem"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
@@ -16,13 +17,9 @@ test_new_config_single_key :: proc(t: ^testing.T) {
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(&cfg) defer delete_config(&cfg)
testing.expect(t, len(cfg.keys) == 1, "should have 1 key") testing.expect_value(t, len(cfg.keys), 1)
testing.expect(t, cfg.keys[0].private == "/home/user/.ssh/id_ed25519", "Private path mismatch") testing.expect_value(t, cfg.keys[0].private, "/home/user/.ssh/id_ed25519")
testing.expect( testing.expect_value(t, cfg.keys[0].public, "/home/user/.ssh/id_ed25519.pub")
t,
cfg.keys[0].public == "/home/user/.ssh/id_ed25519.pub",
"Public path mismatch",
)
} }
@(test) @(test)
@@ -31,9 +28,9 @@ test_new_config_multiple_keys :: proc(t: ^testing.T) {
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(&cfg) defer delete_config(&cfg)
testing.expect(t, len(cfg.keys) == 2, "should have 2 keys") testing.expect_value(t, len(cfg.keys), 2)
testing.expect(t, cfg.keys[0].private == "/home/user/.ssh/id_ed25519") testing.expect_value(t, cfg.keys[0].private, "/home/user/.ssh/id_ed25519")
testing.expect(t, cfg.keys[1].private == "/home/user/.ssh/id_rsa") testing.expect_value(t, cfg.keys[1].private, "/home/user/.ssh/id_rsa")
} }
@(test) @(test)
@@ -42,7 +39,7 @@ test_new_config_empty_keys :: proc(t: ^testing.T) {
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(&cfg) defer delete_config(&cfg)
testing.expect(t, len(cfg.keys) == 0, "should have 0 keys") testing.expect_value(t, len(cfg.keys), 0)
} }
@(test) @(test)
@@ -51,10 +48,10 @@ test_new_config_scan_defaults :: proc(t: ^testing.T) {
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(&cfg) defer delete_config(&cfg)
testing.expect(t, cfg.scan_config.matcher == "\\.env", "matcher should be \\.env") testing.expect_value(t, cfg.scan_config.matcher, "\\.env")
testing.expect(t, len(cfg.scan_config.exclude) == 4, "should have 4 exclude patterns") testing.expect_value(t, len(cfg.scan_config.exclude), 4)
testing.expect(t, len(cfg.scan_config.include) == 1, "should have 1 include path") testing.expect_value(t, len(cfg.scan_config.include), 1)
testing.expect(t, cfg.scan_config.include[0] == "~", "include should be ~") testing.expect_value(t, cfg.scan_config.include[0], "~")
} }
@(test) @(test)
@@ -65,7 +62,7 @@ test_new_config_exclude_patterns :: proc(t: ^testing.T) {
expected := []string{"*\\.envrc", "\\.local/", "node_modules", "vendor"} expected := []string{"*\\.envrc", "\\.local/", "node_modules", "vendor"}
for i in 0 ..< len(expected) { for i in 0 ..< len(expected) {
testing.expect(t, cfg.scan_config.exclude[i] == expected[i]) testing.expect_value(t, cfg.scan_config.exclude[i], expected[i])
} }
} }
@@ -75,7 +72,7 @@ test_save_load_config_roundtrip :: proc(t: ^testing.T) {
defer os.remove_all(base) defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect_value(t, err, nil)
cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}, cfgPath) cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}, cfgPath)
defer delete_config(&cfg) defer delete_config(&cfg)
@@ -87,13 +84,13 @@ test_save_load_config_roundtrip :: proc(t: ^testing.T) {
if !ok do return if !ok do return
defer delete_config(&loaded) defer delete_config(&loaded)
testing.expect(t, len(loaded.keys) == 1, "should have 1 key") testing.expect_value(t, len(loaded.keys), 1)
testing.expect(t, loaded.keys[0].private == "/home/user/.ssh/id_ed25519") testing.expect_value(t, loaded.keys[0].private, "/home/user/.ssh/id_ed25519")
testing.expect(t, loaded.keys[0].public == "/home/user/.ssh/id_ed25519.pub") testing.expect_value(t, loaded.keys[0].public, "/home/user/.ssh/id_ed25519.pub")
testing.expect(t, loaded.scan_config.matcher == "\\.env") testing.expect_value(t, loaded.scan_config.matcher, "\\.env")
testing.expect(t, len(loaded.scan_config.exclude) == 4) testing.expect_value(t, len(loaded.scan_config.exclude), 4)
testing.expect(t, len(loaded.scan_config.include) == 1) testing.expect_value(t, len(loaded.scan_config.include), 1)
testing.expect(t, loaded.scan_config.include[0] == "~") testing.expect_value(t, loaded.scan_config.include[0], "~")
} }
@(test) @(test)
@@ -108,7 +105,7 @@ test_save_config_no_clobber :: proc(t: ^testing.T) {
defer os.remove_all(base) defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect_value(t, err, nil)
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath) cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
defer delete_config(&cfg) defer delete_config(&cfg)
@@ -125,7 +122,7 @@ test_save_config_force_overwrites :: proc(t: ^testing.T) {
defer os.remove_all(base) defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect_value(t, err, nil)
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath) cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
defer delete_config(&cfg) defer delete_config(&cfg)
@@ -140,12 +137,8 @@ test_save_config_force_overwrites :: proc(t: ^testing.T) {
if !ok do return if !ok do return
defer delete_config(&loaded) defer delete_config(&loaded)
testing.expect(t, len(loaded.keys) == 1, "should have 1 key") testing.expect_value(t, len(loaded.keys), 1)
testing.expect( testing.expect_value(t, loaded.keys[0].private, "/home/user/.ssh/key2")
t,
loaded.keys[0].private == "/home/user/.ssh/key2",
"should be the overwritten key",
)
} }
@(test) @(test)
@@ -190,7 +183,7 @@ test_search_paths_expands_tilde :: proc(t: ^testing.T) {
paths := search_paths(cfg, context.temp_allocator) paths := search_paths(cfg, context.temp_allocator)
testing.expect(t, len(paths) == 1, "should have 1 path") testing.expect_value(t, len(paths), 1)
if len(paths) > 0 { if len(paths) > 0 {
testing.expectf( testing.expectf(
t, t,
@@ -202,3 +195,14 @@ test_search_paths_expands_tilde :: proc(t: ^testing.T) {
} }
} }
@(test)
test_search_paths_no_leak :: proc(t: ^testing.T) {
cfg := Config {
scan_config = ScanConfig{include = make([dynamic]string, 0, 1)},
}
defer delete(cfg.scan_config.include)
append(&cfg.scan_config.include, "/tmp")
_ = search_paths(cfg, context.allocator)
}
+14 -14
View File
@@ -5,7 +5,7 @@ import "core:mem"
import "core:os" import "core:os"
MAGIC :: "ENVR" MAGIC :: "ENVR"
MAGIC_BYTES := [4]u8{u8('E'), u8('N'), u8('V'), u8('R')} MAGIC_BYTES: [4]u8 = MAGIC
RECIPIENT_ENTRY_SIZE :: RECIPIENT_ENTRY_SIZE ::
CRYPTO_BOX_PUBLICKEY_BYTES + CRYPTO_BOX_PUBLICKEY_BYTES +
@@ -60,7 +60,7 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
&sym_key[0], &sym_key[0],
) )
if rc != 0 { if rc != 0 {
fmt.println("Error: symmetric encryption failed") fmt.eprintln("Error: symmetric encryption failed")
delete(secret_ct) delete(secret_ct)
return return
} }
@@ -84,7 +84,7 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
&x25519_pairs[0].Private[0], &x25519_pairs[0].Private[0],
) )
if rc != 0 { if rc != 0 {
fmt.printf("Error: failed to encrypt for recipient %d\n", i) fmt.eprintf("Error: failed to encrypt for recipient %d\n", i)
delete(entries) delete(entries)
delete(secret_ct) delete(secret_ct)
return return
@@ -132,13 +132,13 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: bool) { decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: bool) {
if len(ciphertext) < HEADER_SIZE { if len(ciphertext) < HEADER_SIZE {
fmt.println("Error: ciphertext too short (header)") fmt.eprintln("Error: ciphertext too short (header)")
return return
} }
for i in 0 ..< 4 { for i in 0 ..< 4 {
if ciphertext[i] != MAGIC_BYTES[i] { if ciphertext[i] != MAGIC_BYTES[i] {
fmt.println("Error: invalid magic bytes") fmt.eprintln("Error: invalid magic bytes")
return return
} }
} }
@@ -166,7 +166,7 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
recipients_end := offset + int(num_recipients) * RECIPIENT_ENTRY_SIZE recipients_end := offset + int(num_recipients) * RECIPIENT_ENTRY_SIZE
if recipients_end > len(ciphertext) { if recipients_end > len(ciphertext) {
fmt.println("Error: ciphertext too short (recipient data)") fmt.eprintln("Error: ciphertext too short (recipient data)")
return return
} }
@@ -222,7 +222,7 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
} }
if !found { if !found {
fmt.println("Error: no matching recipient found") fmt.eprintln("Error: no matching recipient found")
return return
} }
@@ -236,14 +236,14 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
&x25519_pairs[matched_pi].Private[0], &x25519_pairs[matched_pi].Private[0],
) )
if rc != 0 { if rc != 0 {
fmt.println("Error: failed to decrypt symmetric key") fmt.eprintln("Error: failed to decrypt symmetric key")
return return
} }
ct_data := ciphertext[recipients_end:] ct_data := ciphertext[recipients_end:]
pt_len := len(ct_data) - CRYPTO_SECRETBOX_MAC_BYTES pt_len := len(ct_data) - CRYPTO_SECRETBOX_MAC_BYTES
if pt_len < 0 { if pt_len < 0 {
fmt.println("Error: ciphertext too short (no encrypted data)") fmt.eprintln("Error: ciphertext too short (no encrypted data)")
return return
} }
@@ -260,7 +260,7 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
&sym_key[0], &sym_key[0],
) )
if rc != 0 { if rc != 0 {
fmt.println("Error: symmetric decryption failed") fmt.eprintln("Error: symmetric decryption failed")
delete(plaintext) delete(plaintext)
return return
} }
@@ -285,21 +285,21 @@ ssh_to_x25519 :: proc(
for i in 0 ..< len(keys) { for i in 0 ..< len(keys) {
ssh_kp, parse_ok := parse_ssh_private_key(keys[i].private) ssh_kp, parse_ok := parse_ssh_private_key(keys[i].private)
if !parse_ok { if !parse_ok {
fmt.printf("Error: failed to parse SSH private key: %s\n", keys[i].private) fmt.eprintf("Error: failed to parse SSH private key: %s\n", keys[i].private)
delete(pairs) delete(pairs)
return pairs, false return pairs, false
} }
ssh_pub, pub_ok := parse_ssh_public_key(keys[i].public) ssh_pub, pub_ok := parse_ssh_public_key(keys[i].public)
if !pub_ok { if !pub_ok {
fmt.printf("Error: failed to parse SSH public key: %s\n", keys[i].public) fmt.eprintf("Error: failed to parse SSH public key: %s\n", keys[i].public)
delete(pairs) delete(pairs)
return pairs, false return pairs, false
} }
pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0]) pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0])
if pk_rc != 0 { if pk_rc != 0 {
fmt.println("Error: failed to convert ed25519 public key to curve25519") fmt.eprintln("Error: failed to convert ed25519 public key to curve25519")
delete(pairs) delete(pairs)
return pairs, false return pairs, false
} }
@@ -314,7 +314,7 @@ ssh_to_x25519 :: proc(
sk_rc := crypto_sign_ed25519_sk_to_curve25519(&pairs[i].Private[0], &ed25519_sk[0]) sk_rc := crypto_sign_ed25519_sk_to_curve25519(&pairs[i].Private[0], &ed25519_sk[0])
if sk_rc != 0 { if sk_rc != 0 {
fmt.println("Error: failed to convert ed25519 private key to curve25519") fmt.eprintln("Error: failed to convert ed25519 private key to curve25519")
delete(pairs) delete(pairs)
return pairs, false return pairs, false
} }
+12 -22
View File
@@ -27,13 +27,10 @@ test_encrypt_decrypt_roundtrip :: proc(t: ^testing.T) {
testing.expect(t, dec_ok, "decryption should succeed") testing.expect(t, dec_ok, "decryption should succeed")
defer delete(decrypted) defer delete(decrypted)
testing.expect( testing.expect_value(t, len(decrypted), len(original))
t, // TODO: Should this be a loop?
len(decrypted) == len(original),
fmt.tprintf("expected %d bytes, got %d", len(original), len(decrypted)),
)
for i in 0 ..< len(original) { for i in 0 ..< len(original) {
testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at index %d", i)) testing.expect_value(t, decrypted[i], original[i])
} }
} }
@@ -56,16 +53,8 @@ test_encrypt_decrypt_multi_recipient :: proc(t: ^testing.T) {
defer delete(decrypted2) defer delete(decrypted2)
for i in 0 ..< len(original) { for i in 0 ..< len(original) {
testing.expect( testing.expect_value(t, decrypted1[i], original[i])
t, testing.expect_value(t, decrypted2[i], original[i])
decrypted1[i] == original[i],
fmt.tprintf("key1: byte mismatch at %d", i),
)
testing.expect(
t,
decrypted2[i] == original[i],
fmt.tprintf("key2: byte mismatch at %d", i),
)
} }
} }
@@ -96,7 +85,7 @@ test_encrypt_empty_plaintext :: proc(t: ^testing.T) {
testing.expect(t, dec_ok, "decryption should succeed") testing.expect(t, dec_ok, "decryption should succeed")
defer delete(decrypted) defer delete(decrypted)
testing.expect(t, len(decrypted) == 0, "decrypted empty data should be empty") testing.expect_value(t, len(decrypted), 0)
} }
@(test) @(test)
@@ -113,8 +102,9 @@ test_recipient_can_decrypt_senders_data :: proc(t: ^testing.T) {
testing.expect(t, dec_ok, "second recipient should decrypt without the sender key present") testing.expect(t, dec_ok, "second recipient should decrypt without the sender key present")
defer delete(decrypted) defer delete(decrypted)
// TODO: Should this be a loop?
for i in 0 ..< len(original) { for i in 0 ..< len(original) {
testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at %d", i)) testing.expect_value(t, decrypted[i], original[i])
} }
} }
@@ -128,9 +118,9 @@ test_ciphertext_has_magic :: proc(t: ^testing.T) {
defer delete(encrypted) defer delete(encrypted)
testing.expect(t, len(encrypted) >= 4, "ciphertext should have at least 4 bytes") testing.expect(t, len(encrypted) >= 4, "ciphertext should have at least 4 bytes")
testing.expect(t, encrypted[0] == u8('E'), "magic byte 0") testing.expect_value(t, encrypted[0], u8('E'))
testing.expect(t, encrypted[1] == u8('N'), "magic byte 1") testing.expect_value(t, encrypted[1], u8('N'))
testing.expect(t, encrypted[2] == u8('V'), "magic byte 2") testing.expect_value(t, encrypted[2], u8('V'))
testing.expect(t, encrypted[3] == u8('R'), "magic byte 3") testing.expect_value(t, encrypted[3], u8('R'))
} }
+46 -54
View File
@@ -4,7 +4,6 @@ import "base:runtime"
import "core:crypto/hash" import "core:crypto/hash"
import "core:encoding/hex" import "core:encoding/hex"
import "core:encoding/ini" import "core:encoding/ini"
import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:mem" import "core:mem"
import "core:os" import "core:os"
@@ -94,14 +93,14 @@ db_init :: proc() -> (db: Db, ok: bool) {
conn: sqlite.Db conn: sqlite.Db
rc := sqlite.open(":memory:", &conn) rc := sqlite.open(":memory:", &conn)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error opening in-memory database: %s\n", sqlite.errmsg(conn)) fmt.eprintf("Error opening in-memory database: %s\n", sqlite.errmsg(conn))
return return
} }
create_sql: cstring = "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" create_sql: cstring = "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)"
rc = sqlite.exec(conn, create_sql, nil, nil, nil) rc = sqlite.exec(conn, create_sql, nil, nil, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error creating table: %s\n", sqlite.errmsg(conn)) fmt.eprintf("Error creating table: %s\n", sqlite.errmsg(conn))
sqlite.close(conn) sqlite.close(conn)
return return
} }
@@ -119,14 +118,14 @@ db_allocator :: proc(db: ^Db) -> mem.Allocator {
db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool { db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator) encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator)
if read_err != nil { if read_err != nil {
fmt.printf("Error reading encrypted database: %v\n", read_err) fmt.eprintf("Error reading encrypted database: %v\n", read_err)
return false return false
} }
// TODO: Use context.temp_allocator // TODO: Use context.temp_allocator
plaintext, dec_ok := decrypt(encrypted_data, db.cfg.keys[:]) plaintext, dec_ok := decrypt(encrypted_data, db.cfg.keys[:])
if !dec_ok { if !dec_ok {
fmt.println("Error: decryption failed") fmt.eprintln("Error: decryption failed")
return false return false
} }
defer delete(plaintext) defer delete(plaintext)
@@ -134,7 +133,7 @@ db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
n := i64(len(plaintext)) n := i64(len(plaintext))
buf := sqlite.malloc64(n) buf := sqlite.malloc64(n)
if buf == nil { if buf == nil {
fmt.println("Error: failed to allocate buffer for deserialization") fmt.eprintln("Error: failed to allocate buffer for deserialization")
return false return false
} }
copy(buf[:len(plaintext)], plaintext) copy(buf[:len(plaintext)], plaintext)
@@ -144,7 +143,7 @@ db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
rc := sqlite.deserialize(db.conn, "main", buf, n, n, flags) rc := sqlite.deserialize(db.conn, "main", buf, n, n, flags)
if rc != sqlite.OK { if rc != sqlite.OK {
sqlite.free(buf) sqlite.free(buf)
fmt.printf("Error deserializing database: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error deserializing database: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
@@ -167,14 +166,14 @@ db_close :: proc(db: ^Db) {
if db.changed && len(db.cfg.keys) > 0 { if db.changed && len(db.cfg.keys) > 0 {
rc := sqlite.exec(db.conn, "VACUUM", nil, nil, nil) rc := sqlite.exec(db.conn, "VACUUM", nil, nil, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error vacuuming database: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error vacuuming database: %s\n", sqlite.errmsg(db.conn))
return return
} }
sz: i64 sz: i64
data := sqlite.serialize(db.conn, "main", &sz, 0) data := sqlite.serialize(db.conn, "main", &sz, {})
if data == nil { if data == nil {
fmt.println("Error: failed to serialize database") fmt.eprintln("Error: failed to serialize database")
return return
} }
defer sqlite.free(data) defer sqlite.free(data)
@@ -195,7 +194,7 @@ db_close :: proc(db: ^Db) {
write_err := os.write_entire_file(data_path, encrypted) write_err := os.write_entire_file(data_path, encrypted)
delete(encrypted) delete(encrypted)
if write_err != nil { if write_err != nil {
fmt.printf("Error writing encrypted database: %v\n", write_err) fmt.eprintf("Error writing encrypted database: %v\n", write_err)
return return
} }
@@ -214,7 +213,7 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
nil, nil,
) )
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error preparing query: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error preparing query: %s\n", sqlite.errmsg(db.conn))
return []EnvFile{}, false return []EnvFile{}, false
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
@@ -228,18 +227,17 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
break break
} }
if rc != sqlite.ROW { if rc != sqlite.ROW {
fmt.printf("Error stepping query: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error stepping query: %s\n", sqlite.errmsg(db.conn))
#no_bounds_check return results[:], false #no_bounds_check return results[:], false
} }
remotes_json := string(sqlite.column_text(stmt, 1)) remotes_raw := string(sqlite.column_text(stmt, 1))
remotes: [dynamic]string = --- split := strings.split_lines(remotes_raw, context.temp_allocator)
if len(remotes_json) > 0 { remotes := make([dynamic]string, 0, len(split), allocator = allocator)
err := json.unmarshal_string(remotes_json, &remotes, allocator = allocator) for s in split {
if err != nil { append(&remotes, strings.clone(s, allocator))
fmt.eprintf("Warning: malformed remotes JSON: %v\n", err)
}
} }
path := clone_cstring(sqlite.column_text(stmt, 0), allocator) path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
append( append(
@@ -259,11 +257,7 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
// TODO: Should we use context.temp_allocator for proc scoped lifetimes? // TODO: Should we use context.temp_allocator for proc scoped lifetimes?
db_insert :: proc(db: ^Db, file: EnvFile) -> bool { db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
remotes_json, marshal_err := json.marshal(file.remotes, allocator = context.temp_allocator) remotes := strings.join(file.remotes[:], "\n", allocator = context.temp_allocator)
if marshal_err != nil {
fmt.printf("Error marshaling remotes: %v\n", marshal_err)
return false
}
sql: cstring = sql: cstring =
"INSERT OR REPLACE INTO " + "INSERT OR REPLACE INTO " +
@@ -271,7 +265,7 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
stmt: sqlite.Stmt stmt: sqlite.Stmt
rc := sqlite.prepare_v2(db.conn, sql, -1, &stmt, nil) rc := sqlite.prepare_v2(db.conn, sql, -1, &stmt, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error preparing insert: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error preparing insert: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
@@ -281,15 +275,15 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
defer delete(cpath) defer delete(cpath)
rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) rc = sqlite.bind_text(stmt, 1, cpath, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error binding path: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
cremotes := to_cstring(string(remotes_json)) cremotes := to_cstring(remotes)
defer delete(cremotes) defer delete(cremotes)
rc = sqlite.bind_text(stmt, 2, cremotes, -1, nil) rc = sqlite.bind_text(stmt, 2, cremotes, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding remotes: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error binding remotes: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
@@ -297,7 +291,7 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
defer delete(csha) defer delete(csha)
rc = sqlite.bind_text(stmt, 3, csha, -1, nil) rc = sqlite.bind_text(stmt, 3, csha, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding sha256: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error binding sha256: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
@@ -305,13 +299,13 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
defer delete(ccontents) defer delete(ccontents)
rc = sqlite.bind_text(stmt, 4, ccontents, -1, nil) rc = sqlite.bind_text(stmt, 4, ccontents, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding contents: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error binding contents: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc != sqlite.DONE { if rc != sqlite.DONE {
fmt.printf("Error inserting: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error inserting: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
@@ -329,7 +323,7 @@ db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
stmt: sqlite.Stmt stmt: sqlite.Stmt
rc := sqlite.prepare_v2(db.conn, sql, -1, &stmt, nil) rc := sqlite.prepare_v2(db.conn, sql, -1, &stmt, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error preparing fetch: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error preparing fetch: %s\n", sqlite.errmsg(db.conn))
return EnvFile{}, false return EnvFile{}, false
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
@@ -340,26 +334,24 @@ db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
defer delete(cpath, allocator) defer delete(cpath, allocator)
rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) rc = sqlite.bind_text(stmt, 1, cpath, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error binding path: %s\n", sqlite.errmsg(db.conn))
return EnvFile{}, false return EnvFile{}, false
} }
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc == sqlite.DONE { if rc == sqlite.DONE {
fmt.printf("No file found with path: %s\n", path) fmt.eprintf("No file found with path: %s\n", path)
return EnvFile{}, false return EnvFile{}, false
} }
if rc != sqlite.ROW { if rc != sqlite.ROW {
fmt.printf("Error fetching: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error fetching: %s\n", sqlite.errmsg(db.conn))
return EnvFile{}, false return EnvFile{}, false
} }
remotes_json := string(sqlite.column_text(stmt, 1)) remotes_raw := string(sqlite.column_text(stmt, 1))
remotes: [dynamic]string = --- split := strings.split_lines(remotes_raw, context.temp_allocator)
if len(remotes_json) > 0 { remotes := make([dynamic]string, 0, len(split), allocator = allocator)
err := json.unmarshal_string(remotes_json, &remotes, allocator = allocator) for s in split {
if err != nil { append(&remotes, strings.clone(s, allocator))
fmt.eprintf("Warning: malformed remotes JSON: %v\n", err)
}
} }
file_path := clone_cstring(sqlite.column_text(stmt, 0), allocator) file_path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
@@ -379,7 +371,7 @@ db_delete :: proc(db: ^Db, path: string) -> bool {
stmt: sqlite.Stmt stmt: sqlite.Stmt
rc := sqlite.prepare_v2(db.conn, sql, -1, &stmt, nil) rc := sqlite.prepare_v2(db.conn, sql, -1, &stmt, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error preparing delete: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error preparing delete: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
@@ -388,17 +380,17 @@ db_delete :: proc(db: ^Db, path: string) -> bool {
defer delete(cpath) defer delete(cpath)
rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) rc = sqlite.bind_text(stmt, 1, cpath, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error binding path: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc != sqlite.DONE { if rc != sqlite.DONE {
fmt.printf("Error deleting: %s\n", sqlite.errmsg(db.conn)) fmt.eprintf("Error deleting: %s\n", sqlite.errmsg(db.conn))
return false return false
} }
if sqlite.changes(db.conn) == 0 { if sqlite.changes(db.conn) == 0 {
fmt.printf("No file found with path: %s\n", path) fmt.eprintf("No file found with path: %s\n", path)
return false return false
} }
@@ -410,7 +402,7 @@ db_delete :: proc(db: ^Db, path: string) -> bool {
new_env_file :: proc(path: string) -> (EnvFile, bool) { new_env_file :: proc(path: string) -> (EnvFile, bool) {
abs_path, abs_err := filepath.abs(path) abs_path, abs_err := filepath.abs(path)
if abs_err != nil { if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err) fmt.eprintf("Error getting absolute path: %v\n", abs_err)
return EnvFile{}, false return EnvFile{}, false
} }
@@ -421,7 +413,7 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
data, read_err := os.read_entire_file_from_path(abs_path, context.allocator) data, read_err := os.read_entire_file_from_path(abs_path, context.allocator)
if read_err != nil { if read_err != nil {
fmt.printf("Error reading file %s: %v\n", abs_path, read_err) fmt.eprintf("Error reading file %s: %v\n", abs_path, read_err)
return EnvFile{}, false return EnvFile{}, false
} }
@@ -499,7 +491,7 @@ db_persist :: proc(db: ^Db, f: ^EnvFile, old_path: string) -> bool {
} }
try_move_dir :: proc(db: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, SyncError) { try_move_dir :: proc(db: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, SyncError) {
roots, ok := find_git_roots(db.cfg) roots, ok := find_git_roots(db.cfg, context.allocator)
if !ok { if !ok {
return false, .GitRootFailed return false, .GitRootFailed
} }
@@ -524,7 +516,7 @@ try_move_dir :: proc(db: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, S
case 0: case 0:
return false, .DirMissing return false, .DirMissing
case 1: case 1:
f.dir, _ = strings.clone(matched_dir, allocator) f.dir = strings.clone(matched_dir, allocator)
base := filepath.base(f.path) base := filepath.base(f.path)
new_path, _ := filepath.join({f.dir, base}, allocator) new_path, _ := filepath.join({f.dir, base}, allocator)
f.path = new_path f.path = new_path
@@ -565,7 +557,7 @@ get_git_remotes :: proc(dir: string, allocator: mem.Allocator) -> [dynamic]strin
} }
if !found { if !found {
// FIXME: Currently leaks when adding a file with envr scan // FIXME: Currently leaks when adding a file with envr scan
cloned, _ := strings.clone(url, allocator) cloned := strings.clone(url, allocator)
append(&remotes, cloned) append(&remotes, cloned)
} }
} }
@@ -583,7 +575,7 @@ to_cstring :: proc {
string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring { string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring {
cs, err := strings.clone_to_cstring(s, allocator) cs, err := strings.clone_to_cstring(s, allocator)
if err != nil { if err != nil {
fmt.printf("Failed to convert string to cstring: %v\n", err) fmt.eprintf("Failed to convert string to cstring: %v\n", err)
panic("Allocation Exception") panic("Allocation Exception")
} }
return cs return cs
@@ -593,7 +585,7 @@ string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring
clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string { clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string {
str, err := strings.clone_from_cstring(c, allocator) str, err := strings.clone_from_cstring(c, allocator)
if err != nil { if err != nil {
fmt.printf("Failed to convert string to cstring: %v\n", err) fmt.eprintf("Failed to convert string to cstring: %v\n", err)
delete(str) delete(str)
panic("Allocation Exception") panic("Allocation Exception")
} }
+11 -19
View File
@@ -67,10 +67,10 @@ test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
defer delete(encrypted) defer delete(encrypted)
testing.expect(t, len(encrypted) >= HEADER_SIZE, "ciphertext should have header") testing.expect(t, len(encrypted) >= HEADER_SIZE, "ciphertext should have header")
testing.expect(t, encrypted[0] == u8('E'), "magic byte 0") testing.expect_value(t, encrypted[0], u8('E'))
testing.expect(t, encrypted[1] == u8('N'), "magic byte 1") testing.expect_value(t, encrypted[1], u8('N'))
testing.expect(t, encrypted[2] == u8('V'), "magic byte 2") testing.expect_value(t, encrypted[2], u8('V'))
testing.expect(t, encrypted[3] == u8('R'), "magic byte 3") testing.expect_value(t, encrypted[3], u8('R'))
plaintext, dec_ok := decrypt(encrypted, cfg.keys[:]) plaintext, dec_ok := decrypt(encrypted, cfg.keys[:])
testing.expect(t, dec_ok, "decryption should succeed") testing.expect(t, dec_ok, "decryption should succeed")
@@ -142,7 +142,7 @@ test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
} }
defer delete(plaintext) defer delete(plaintext)
testing.expect(t, len(plaintext) == len(sqlite_data), "size mismatch after file round-trip") testing.expect_value(t, len(plaintext), len(sqlite_data))
} }
@(test) @(test)
@@ -189,7 +189,7 @@ test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
copy(buf[:len(plaintext)], plaintext) copy(buf[:len(plaintext)], plaintext)
rc = sqlite.deserialize(mem_db, "main", buf, n, n, {.FREEONCLOSE, .RESIZEABLE}) rc = sqlite.deserialize(mem_db, "main", buf, n, n, {.FREEONCLOSE, .RESIZEABLE})
testing.expect(t, rc == sqlite.OK, "deserialize should succeed") testing.expect_value(t, rc, sqlite.OK)
if rc != sqlite.OK { if rc != sqlite.OK {
sqlite.free(buf) sqlite.free(buf)
return return
@@ -198,14 +198,14 @@ test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
sql: cstring = "SELECT path FROM envr_env_files" sql: cstring = "SELECT path FROM envr_env_files"
stmt: sqlite.Stmt stmt: sqlite.Stmt
rc = sqlite.prepare_v2(mem_db, sql, -1, &stmt, nil) rc = sqlite.prepare_v2(mem_db, sql, -1, &stmt, nil)
testing.expect(t, rc == sqlite.OK, "prepare failed") testing.expect_value(t, rc, sqlite.OK)
if rc != sqlite.OK { if rc != sqlite.OK {
return return
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
testing.expect(t, rc == sqlite.ROW, "expected at least one row") testing.expect_value(t, rc, sqlite.ROW)
if rc == sqlite.ROW { if rc == sqlite.ROW {
path := string(sqlite.column_text(stmt, 0)) path := string(sqlite.column_text(stmt, 0))
testing.expect(t, len(path) > 0, "path should not be empty") testing.expect(t, len(path) > 0, "path should not be empty")
@@ -275,15 +275,7 @@ test_full_db_cycle :: proc(t: ^testing.T) {
} }
defer delete(plaintext2) defer delete(plaintext2)
testing.expect( testing.expect_value(t, len(plaintext2), len(original_data))
t,
len(plaintext2) == len(original_data),
fmt.tprintf(
"double round-trip size mismatch: expected %d, got %d",
len(original_data),
len(plaintext2),
),
)
os.remove(data_path) os.remove(data_path)
os.remove(envr_dir_path) os.remove(envr_dir_path)
@@ -317,7 +309,7 @@ test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) {
return return
} }
testing.expect(t, len(x25519_pairs) == 1, "should have 1 x25519 keypair") testing.expect_value(t, len(x25519_pairs), 1)
} }
@(test) @(test)
@@ -327,7 +319,7 @@ test_config_load_with_fixture_key :: proc(t: ^testing.T) {
delete(cfg.keys) delete(cfg.keys)
} }
testing.expect(t, len(cfg.keys) == 1, "should have 1 key") testing.expect_value(t, len(cfg.keys), 1)
key := cfg.keys[0] key := cfg.keys[0]
+91 -24
View File
@@ -7,6 +7,7 @@ import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
import "core:sys/posix"
import "core:testing" import "core:testing"
import "sqlite" import "sqlite"
@@ -81,7 +82,7 @@ test_db_insert_or_replace :: proc(t: ^testing.T) {
results, list_ok := db_list(&db) results, list_ok := db_list(&db)
testing.expect(t, list_ok, "list should succeed") testing.expect(t, list_ok, "list should succeed")
testing.expect(t, len(results) == 1, "should have 1 row, not 2") testing.expect_value(t, len(results), 1)
fetched, fetch_ok := db_fetch(&db, "/project/.env") fetched, fetch_ok := db_fetch(&db, "/project/.env")
testing.expect(t, fetch_ok, "fetch should succeed") testing.expect(t, fetch_ok, "fetch should succeed")
@@ -149,7 +150,7 @@ test_db_list_empty :: proc(t: ^testing.T) {
results, list_ok := db_list(&db) results, list_ok := db_list(&db)
testing.expect(t, list_ok, "list should succeed on empty db") testing.expect(t, list_ok, "list should succeed on empty db")
testing.expect(t, len(results) == 0, "should have 0 rows") testing.expect_value(t, len(results), 0)
} }
@(test) @(test)
@@ -196,7 +197,7 @@ test_db_serialize :: proc(t: ^testing.T) {
db_insert(&db, f) db_insert(&db, f)
sz: i64 sz: i64
data := sqlite.serialize(db.conn, "main", &sz, 0) data := sqlite.serialize(db.conn, "main", &sz, {})
testing.expect(t, data != nil, "serialize should return non-nil") testing.expect(t, data != nil, "serialize should return non-nil")
if data == nil do return if data == nil do return
defer sqlite.free(data) defer sqlite.free(data)
@@ -276,11 +277,11 @@ test_get_git_remotes_single :: proc(t: ^testing.T) {
config_content := "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n" config_content := "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n"
config_path := fmt.tprintf("%s/config", git_dir) config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator) remotes := get_git_remotes(base, context.temp_allocator)
testing.expect(t, len(remotes) == 1, "should find 1 remote") testing.expect_value(t, len(remotes), 1)
if len(remotes) != 1 do return if len(remotes) != 1 do return
testing.expect_value(t, remotes[0], "git@github.com:user/repo.git") testing.expect_value(t, remotes[0], "git@github.com:user/repo.git")
} }
@@ -296,11 +297,11 @@ test_get_git_remotes_multiple :: proc(t: ^testing.T) {
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n[remote \"upstream\"]\n\turl = https://gitlab.com/upstream/repo.git\n" config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n[remote \"upstream\"]\n\turl = https://gitlab.com/upstream/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir) config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator) remotes := get_git_remotes(base, context.temp_allocator)
testing.expect(t, len(remotes) == 2, "should find 2 remotes") testing.expect_value(t, len(remotes), 2)
} }
@(test) @(test)
@@ -310,7 +311,7 @@ test_get_git_remotes_no_config :: proc(t: ^testing.T) {
remotes := get_git_remotes(base, context.temp_allocator) remotes := get_git_remotes(base, context.temp_allocator)
testing.expect(t, len(remotes) == 0, "should return empty when no .git/config") testing.expect_value(t, len(remotes), 0)
} }
@(test) @(test)
@@ -324,11 +325,11 @@ test_get_git_remotes_no_remotes :: proc(t: ^testing.T) {
config_content := "[core]\n\trepositoryformatversion = 0\n\tbare = false\n" config_content := "[core]\n\trepositoryformatversion = 0\n\tbare = false\n"
config_path := fmt.tprintf("%s/config", git_dir) config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator) remotes := get_git_remotes(base, context.temp_allocator)
testing.expect(t, len(remotes) == 0, "should return empty when no remote sections") testing.expect_value(t, len(remotes), 0)
} }
@(test) @(test)
@@ -338,7 +339,7 @@ test_new_env_file :: proc(t: ^testing.T) {
env_path := fmt.tprintf("%s/.env", base) env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "SECRET=value\n") err := os.write_entire_file(env_path, "SECRET=value\n")
testing.expect(t, err == nil, ".env file should exists") testing.expect_value(t, err, nil)
file, ok := new_env_file(env_path) file, ok := new_env_file(env_path)
testing.expect(t, ok, "new_env_file should succeed") testing.expect(t, ok, "new_env_file should succeed")
@@ -350,8 +351,8 @@ test_new_env_file :: proc(t: ^testing.T) {
testing.expect(t, filepath.is_abs(file.path), "path should be absolute") testing.expect(t, filepath.is_abs(file.path), "path should be absolute")
testing.expect(t, strings.has_suffix(file.path, "/.env"), "path should end with /.env") testing.expect(t, strings.has_suffix(file.path, "/.env"), "path should end with /.env")
testing.expect(t, file.contents == "SECRET=value\n", "contents mismatch") testing.expect_value(t, file.contents, "SECRET=value\n")
testing.expect(t, len(file.sha256) == 64, "sha256 should be 64 hex chars") testing.expect_value(t, len(file.sha256), 64)
} }
@(test) @(test)
@@ -366,7 +367,7 @@ test_closing_db_has_no_leaks :: proc(t: ^testing.T) {
defer os.remove_all(base) defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect_value(t, err, nil)
{ {
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path) cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
@@ -385,7 +386,7 @@ test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
defer os.remove_all(base) defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect_value(t, err, nil)
{ {
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path) cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
@@ -422,7 +423,7 @@ test_db_sync_noop :: proc(t: ^testing.T) {
env_path := fmt.tprintf("%s/.env", base) env_path := fmt.tprintf("%s/.env", base)
content := "KEY=value\n" content := "KEY=value\n"
write_err := os.write_entire_file(env_path, transmute([]u8)content) write_err := os.write_entire_file(env_path, transmute([]u8)content)
testing.expect(t, write_err == nil, "should write .env file") testing.expect_value(t, write_err, nil)
digest := hash.hash_bytes( digest := hash.hash_bytes(
hash.Algorithm.SHA256, hash.Algorithm.SHA256,
@@ -441,8 +442,8 @@ test_db_sync_noop :: proc(t: ^testing.T) {
db_insert(&db, f) db_insert(&db, f)
result, sync_err := db_sync(&db, &f) result, sync_err := db_sync(&db, &f)
testing.expect(t, sync_err == .None, "sync should not error") testing.expect_value(t, sync_err, SyncError.None)
testing.expect(t, result == {}, "should be noop") testing.expect_value(t, result, nil)
} }
@(test) @(test)
@@ -453,7 +454,7 @@ test_db_sync_backed_up :: proc(t: ^testing.T) {
env_path := fmt.tprintf("%s/.env", base) env_path := fmt.tprintf("%s/.env", base)
changed_content := "KEY=changed\n" changed_content := "KEY=changed\n"
write_err := os.write_entire_file(env_path, transmute([]u8)changed_content) write_err := os.write_entire_file(env_path, transmute([]u8)changed_content)
testing.expect(t, write_err == nil, "should write .env file") testing.expect_value(t, write_err, nil)
db, ok := db_init() db, ok := db_init()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
@@ -464,7 +465,7 @@ test_db_sync_backed_up :: proc(t: ^testing.T) {
db_insert(&db, f) db_insert(&db, f)
result, sync_err := db_sync(&db, &f) result, sync_err := db_sync(&db, &f)
testing.expect(t, sync_err == .None, "sync should not error") testing.expect_value(t, sync_err, SyncError.None)
testing.expect(t, .BackedUp in result, "should be backed up") testing.expect(t, .BackedUp in result, "should be backed up")
} }
@@ -485,11 +486,11 @@ test_db_sync_restored :: proc(t: ^testing.T) {
db_insert(&db, f) db_insert(&db, f)
result, err := db_sync(&db, &f) result, err := db_sync(&db, &f)
testing.expect(t, err == .None, "sync should not error") testing.expect_value(t, err, SyncError.None)
testing.expect(t, .Restored in result, "should be restored") testing.expect(t, .Restored in result, "should be restored")
data, read_err := os.read_entire_file_from_path(env_path, context.temp_allocator) data, read_err := os.read_entire_file_from_path(env_path, context.temp_allocator)
testing.expect(t, read_err == nil, "file should exist after restore") testing.expect_value(t, read_err, nil)
if read_err == nil { if read_err == nil {
testing.expect_value(t, string(data), "SECRET=value") testing.expect_value(t, string(data), "SECRET=value")
} }
@@ -522,7 +523,7 @@ test_db_sync_moved :: proc(t: ^testing.T) {
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n" config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir) config_path := fmt.tprintf("%s/config", git_dir)
write_err := os.write_entire_file(config_path, transmute([]u8)config_content) write_err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, write_err == nil, "should write .git/config") testing.expect_value(t, write_err, nil)
db, ok := db_init() db, ok := db_init()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
@@ -540,7 +541,7 @@ test_db_sync_moved :: proc(t: ^testing.T) {
testing.expect(t, db_insert(&db, f), "insert should succeed") testing.expect(t, db_insert(&db, f), "insert should succeed")
result, err := db_sync(&db, &f) result, err := db_sync(&db, &f)
testing.expect(t, err == .None, "sync should not error") testing.expect_value(t, err, SyncError.None)
if err != .None do return if err != .None do return
testing.expect(t, .DirUpdated in result, "should have DirUpdated flag") testing.expect(t, .DirUpdated in result, "should have DirUpdated flag")
testing.expect(t, .Restored in result, "should have Restored flag") testing.expect(t, .Restored in result, "should have Restored flag")
@@ -559,3 +560,69 @@ test_db_sync_moved :: proc(t: ^testing.T) {
} }
} }
@(test)
test_db_close_preserves_inodes :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-inode-*")
defer os.remove_all(base)
envr_dir, _ := filepath.join([]string{base, ".envr"}, context.temp_allocator)
config_path, _ := filepath.join([]string{envr_dir, "config.json"}, context.temp_allocator)
os.mkdir_all(envr_dir)
key_abs, _ := filepath.abs("fixtures/keys/insecure-test-key", context.temp_allocator)
cfg := new_config([]string{key_abs}, config_path)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "save_config should succeed")
// Create database
{
db, ok := db_open(config_path)
defer db_close(&db)
testing.expect(t, ok, "first db_open should succeed")
if !ok do return
env_path, _ := filepath.join([]string{base, ".env"}, context.temp_allocator)
f := make_test_env_file(env_path, "sha", "KEY=value")
db_insert(&db, f)
}
// Record inode of data.envr
data_file, _ := data_path(config_path, context.temp_allocator)
cdata := strings.clone_to_cstring(data_file)
defer delete(cdata)
sb1: posix.stat_t
testing.expect(t, posix.stat(cdata, &sb1) == .OK, "stat should succeed")
inode_before := sb1.st_ino
// Create hardlink
link_path, _ := filepath.join([]string{envr_dir, "data.envr.link"}, context.temp_allocator)
clink := strings.clone_to_cstring(link_path)
defer delete(clink)
testing.expect(t, posix.link(cdata, clink) == .OK, "hardlink should succeed")
// Update database
{
db, ok2 := db_open(config_path)
defer db_close(&db)
testing.expect(t, ok2, "second db_open should succeed")
if !ok2 do return
env_path2, _ := filepath.join([]string{base, ".env2"}, context.temp_allocator)
f2 := make_test_env_file(env_path2, "sha2", "KEY2=value2")
db_insert(&db, f2)
}
// Verify inode preserved
sb2: posix.stat_t
testing.expect(t, posix.stat(cdata, &sb2) == .OK, "stat should succeed after rewrite")
testing.expect(t, sb2.st_ino == inode_before, "data.envr inode should be unchanged")
// Verify hardlink still valid
sb_link: posix.stat_t
testing.expect(t, posix.stat(clink, &sb_link) == .OK, "stat on hardlink should succeed")
testing.expect(t, sb_link.st_ino == inode_before, "hardlink inode should match")
testing.expect(t, sb_link.st_nlink == 2, "hardlink count should still be 2")
}
+11 -10
View File
@@ -5,7 +5,7 @@ Manage your .env files.
### Synopsis ### Synopsis
envr keeps your .env synced to a local, encrypted database. envr keeps your .env synced to a local, encrypted database.
Is a safe and eay way to gather all your .env files in one place where they can Is a safe and easy way to gather all your .env files in one place where they can
easily be backed by another tool such as restic or git. easily be backed by another tool such as restic or git.
All your data is stored in ~/.envr/data.envr All your data is stored in ~/.envr/data.envr
@@ -33,25 +33,26 @@ Select the files you want to back up from the interactive list.
5. If you lose a repository, after re-cloning the repo into the same path it was 5. If you lose a repository, after re-cloning the repo into the same path it was
at before, restore your backup with: at before, restore your backup with:
> envr restore ~/&lt;path to repository&gt;/.env > envr restore ~/<path to repository>/.env
### Options ### Options
``` ```
-h, --help help for envr -h, --help show this documentation
``` ```
### SEE ALSO ### SEE ALSO
* [envr backup](envr_backup.md) - Import a .env file into envr
* [envr check](envr_check.md) - check if files in the current directory are backed up
* [envr edit-config](envr_edit-config.md) - Edit your config with your default editor
* [envr init](envr_init.md) - Set up envr * [envr init](envr_init.md) - Set up envr
* [envr list](envr_list.md) - View your tracked files
* [envr nushell-completion](envr_nushell-completion.md) - Generate custom completions for nushell
* [envr remove](envr_remove.md) - Remove a .env file from your database
* [envr restore](envr_restore.md) - Install a .env file from the database into your file system
* [envr scan](envr_scan.md) - Find and select .env files for backup * [envr scan](envr_scan.md) - Find and select .env files for backup
* [envr sync](envr_sync.md) - Update or restore your env backups * [envr sync](envr_sync.md) - Update or restore your env backups
* [envr backup](envr_backup.md) - Import a .env file into envr
* [envr restore](envr_restore.md) - Restore a .env file from the database
* [envr list](envr_list.md) - View your tracked files
* [envr remove](envr_remove.md) - Remove a .env file from your database
* [envr check](envr_check.md) - Check if files are backed up
* [envr version](envr_version.md) - Show envr's version * [envr version](envr_version.md) - Show envr's version
* [envr edit-config](envr_edit-config.md) - Edit your config with your default editor
* [envr completion](envr_completion.md) - Generate shell completion scripts
* [envr uninstall](envr_uninstall.md) - Remove envr configuration and database
+9 -2
View File
@@ -6,13 +6,20 @@ Import a .env file into envr
envr backup <path> [flags] envr backup <path> [flags]
``` ```
**Arguments:**
* `path` — Path to .env file to backup
### Options ### Options
``` ```
-h, --help help for backup -h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
``` ```
*Alias: `add`*
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+8 -3
View File
@@ -1,18 +1,23 @@
## envr check ## envr check
check if files in the current directory are backed up Check if files are backed up
``` ```
envr check [path] [flags] envr check [path] [flags]
``` ```
**Arguments:**
* `path` — Path to check (defaults to current directory) (optional)
### Options ### Options
``` ```
-h, --help help for check -h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+28
View File
@@ -0,0 +1,28 @@
## envr completion
Generate shell completion scripts
### Synopsis
Supported shells:
nushell
bash
```
envr completion <shell> [flags]
```
**Arguments:**
* `shell` — Shell to generate completions for
### Options
```
-h, --help show this documentation
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+3 -2
View File
@@ -9,10 +9,11 @@ envr edit-config [flags]
### Options ### Options
``` ```
-h, --help help for edit-config -h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+5 -6
View File
@@ -5,9 +5,7 @@ Set up envr
### Synopsis ### Synopsis
The init command generates your initial config and saves it to The init command generates your initial config and saves it to
~/.envr/config in JSON format. ~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to
During setup, you will be prompted to select one or more ssh keys with which to
encrypt your databse. **Make 100% sure** that you have **a remote copy** of this encrypt your databse. **Make 100% sure** that you have **a remote copy** of this
key somewhere, otherwise your data could be lost forever. key somewhere, otherwise your data could be lost forever.
@@ -18,11 +16,12 @@ envr init [flags]
### Options ### Options
``` ```
-f, --force Overwrite an existing config -h, --help show this documentation
-h, --help help for init -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 ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+4 -2
View File
@@ -9,10 +9,12 @@ envr list [flags]
### Options ### Options
``` ```
-h, --help help for list -h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
-o, --output the format of output data (default 'table')
--color Whether or not to colorize output (default 'auto')
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
-23
View File
@@ -1,23 +0,0 @@
## envr nushell-completion
Generate custom completions for nushell
### Synopsis
At time of writing, cobra does not natively support nushell,
so a custom command had to be written
```
envr nushell-completion [flags]
```
### Options
```
-h, --help help for nushell-completion
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+8 -3
View File
@@ -3,16 +3,21 @@
Remove a .env file from your database Remove a .env file from your database
``` ```
envr remove [flags] envr remove <path> [flags]
``` ```
**Arguments:**
* `path` — Path to .env file to remove
### Options ### Options
``` ```
-h, --help help for remove -h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+9 -4
View File
@@ -1,18 +1,23 @@
## envr restore ## envr restore
Install a .env file from the database into your file system Restore a .env file from the database
``` ```
envr restore [flags] envr restore <path> [flags]
``` ```
**Arguments:**
* `path` — Path to .env file to restore
### Options ### Options
``` ```
-h, --help help for restore -h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+3 -2
View File
@@ -9,10 +9,11 @@ envr scan [flags]
### Options ### Options
``` ```
-h, --help help for scan -h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+4 -2
View File
@@ -9,10 +9,12 @@ envr sync [flags]
### Options ### Options
``` ```
-h, --help help for sync -h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
-o, --output the format of output data (default 'table')
--color Whether or not to colorize output (default 'auto')
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+24
View File
@@ -0,0 +1,24 @@
## envr uninstall
Remove envr configuration and database
### Synopsis
This will remove all files generated by envr from your system.
```
envr uninstall [flags]
```
### Options
```
-h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
-f, --force Overwrite existing config
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+1 -3
View File
@@ -9,11 +9,9 @@ envr version [flags]
### Options ### Options
``` ```
-h, --help help for version -h, --help show this documentation
-l, --long Show all version information
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
+20 -41
View File
@@ -21,9 +21,7 @@ test_basic_gitignored :: proc(t: ^testing.T) {
create_file(env, "repo/secrets.env") create_file(env, "repo/secrets.env")
create_file(env, "repo/normal.txt") create_file(env, "repo/normal.txt")
assert_output(t, env, nil, {}, { assert_output(t, env, nil, {}, {"repo/.env", "repo/secrets.env"})
"repo/.env", "repo/secrets.env",
})
} }
@(test) @(test)
@@ -49,9 +47,7 @@ test_negation_pattern :: proc(t: ^testing.T) {
create_file(env, "repo/secrets.env") create_file(env, "repo/secrets.env")
create_file(env, "repo/prod.env") create_file(env, "repo/prod.env")
assert_output(t, env, nil, {}, { assert_output(t, env, nil, {}, {"repo/.env", "repo/secrets.env"})
"repo/.env", "repo/secrets.env",
})
} }
@(test) @(test)
@@ -67,9 +63,7 @@ test_multiple_repos :: proc(t: ^testing.T) {
create_file(env, "repo2/.gitignore", "*.key\n") create_file(env, "repo2/.gitignore", "*.key\n")
create_file(env, "repo2/secret.key") create_file(env, "repo2/secret.key")
assert_output(t, env, nil, {}, { assert_output(t, env, nil, {}, {"repo1/a.env", "repo2/secret.key"})
"repo1/a.env", "repo2/secret.key",
})
} }
@(test) @(test)
@@ -85,9 +79,7 @@ test_nested_repos :: proc(t: ^testing.T) {
create_file(env, "parent/child/.gitignore", "*.key\n") create_file(env, "parent/child/.gitignore", "*.key\n")
create_file(env, "parent/child/api.key") create_file(env, "parent/child/api.key")
assert_output(t, env, nil, {}, { assert_output(t, env, nil, {}, {"parent/top.env", "parent/child/api.key"})
"parent/top.env", "parent/child/api.key",
})
} }
@(test) @(test)
@@ -102,9 +94,7 @@ test_nested_gitignore_read :: proc(t: ^testing.T) {
create_file(env, "repo/sub/secret.txt") create_file(env, "repo/sub/secret.txt")
create_file(env, "repo/sub/.env") create_file(env, "repo/sub/.env")
assert_output(t, env, nil, {}, { assert_output(t, env, nil, {}, {"repo/sub/secret.txt", "repo/sub/.env"})
"repo/sub/secret.txt", "repo/sub/.env",
})
} }
@(test) @(test)
@@ -119,9 +109,7 @@ test_nested_gitignore_negation :: proc(t: ^testing.T) {
create_file(env, "repo/sub/important.log") create_file(env, "repo/sub/important.log")
create_file(env, "repo/sub/debug.log") create_file(env, "repo/sub/debug.log")
assert_output(t, env, nil, {}, { assert_output(t, env, nil, {}, {"repo/sub/debug.log"})
"repo/sub/debug.log",
})
} }
@(test) @(test)
@@ -136,9 +124,7 @@ test_multisegment_pattern :: proc(t: ^testing.T) {
create_file(env, "repo/build/other.txt") create_file(env, "repo/build/other.txt")
create_file(env, "repo/output.txt") create_file(env, "repo/output.txt")
assert_output(t, env, nil, {}, { assert_output(t, env, nil, {}, {"repo/build/output.txt"})
"repo/build/output.txt",
})
} }
@(test) @(test)
@@ -200,7 +186,7 @@ test_multiple_search_dirs :: proc(t: ^testing.T) {
stripped := r stripped := r
if strings.has_prefix(stripped, env.temp_dir) { if strings.has_prefix(stripped, env.temp_dir) {
stripped = stripped[len(env.temp_dir):] stripped = stripped[len(env.temp_dir):]
if len(stripped) > 0 && stripped[0] == '/' { if len(stripped) > 0 && stripped[0] == os.Path_Separator {
stripped = stripped[1:] stripped = stripped[1:]
} }
} }
@@ -234,9 +220,7 @@ test_ignored_dir_descended :: proc(t: ^testing.T) {
create_file(env, "repo/secrets/api.key") create_file(env, "repo/secrets/api.key")
// Ignored dir's contents are emitted AND descended into // Ignored dir's contents are emitted AND descended into
assert_output(t, env, nil, {}, { assert_output(t, env, nil, {}, {"repo/secrets/", "repo/secrets/.env", "repo/secrets/api.key"})
"repo/secrets/", "repo/secrets/.env", "repo/secrets/api.key",
})
} }
@(test) @(test)
@@ -251,10 +235,13 @@ test_nested_ignored_dir :: proc(t: ^testing.T) {
create_file(env, "repo/build/output.txt") create_file(env, "repo/build/output.txt")
create_file(env, "repo/build/sub/deep.env") create_file(env, "repo/build/sub/deep.env")
assert_output(t, env, nil, {}, { assert_output(
"repo/build/", "repo/build/output.txt", t,
"repo/build/sub/", "repo/build/sub/deep.env", env,
}) nil,
{},
{"repo/build/", "repo/build/output.txt", "repo/build/sub/", "repo/build/sub/deep.env"},
)
} }
// ============================================================================ // ============================================================================
@@ -272,10 +259,7 @@ test_excludes_prune_dirs :: proc(t: ^testing.T) {
create_dir(env, "repo/vendor") create_dir(env, "repo/vendor")
create_file(env, "repo/vendor/lib.env") create_file(env, "repo/vendor/lib.env")
assert_output(t, env, nil, assert_output(t, env, nil, {excludes = {"vendor"}}, {"repo/.env"})
{excludes = {"vendor"}},
{"repo/.env"},
)
} }
@(test) @(test)
@@ -289,10 +273,7 @@ test_pattern_filters_results :: proc(t: ^testing.T) {
create_file(env, "repo/secrets.env") create_file(env, "repo/secrets.env")
create_file(env, "repo/master.key") create_file(env, "repo/master.key")
assert_output(t, env, nil, assert_output(t, env, nil, {pattern = "\\.env$"}, {"repo/.env", "repo/secrets.env"})
{pattern = "\\.env$"},
{"repo/.env", "repo/secrets.env"},
)
} }
// ============================================================================ // ============================================================================
@@ -313,8 +294,6 @@ test_fifo_emitted :: proc(t: ^testing.T) {
defer delete(cpath) defer delete(cpath)
linux.mknod(cpath, linux.S_IFIFO | linux.Mode{.IRUSR, .IWUSR}, 0) linux.mknod(cpath, linux.S_IFIFO | linux.Mode{.IRUSR, .IWUSR}, 0)
assert_output(t, env, nil, assert_output(t, env, nil, {pattern = "\\.fifo$"}, {"repo/test.fifo"})
{pattern = "\\.fifo$"},
{"repo/test.fifo"},
)
} }
+11 -3
View File
@@ -16,7 +16,14 @@ RepoPool :: struct {
threads: []^thread.Thread, threads: []^thread.Thread,
} }
find_repos :: proc(roots: []string, results: ^[dynamic]string, thread_count: int) { find_repos :: proc(
roots: []string,
results: ^[dynamic]string,
thread_count: int,
allocator := context.allocator,
) {
// TODO: This may be a code smell
context.allocator = allocator
if len(roots) == 0 do return if len(roots) == 0 do return
pool := new(RepoPool) pool := new(RepoPool)
@@ -26,7 +33,7 @@ find_repos :: proc(roots: []string, results: ^[dynamic]string, thread_count: int
pool.threads = make([]^thread.Thread, thread_count) pool.threads = make([]^thread.Thread, thread_count)
for root in roots { for root in roots {
root_clone, _ := strings.clone(root) root_clone := strings.clone(root)
append(&pool.queue, root_clone) append(&pool.queue, root_clone)
sync.atomic_sema_post(&pool.queue_sema) sync.atomic_sema_post(&pool.queue_sema)
} }
@@ -97,7 +104,7 @@ process_repo_dir :: proc(pool: ^RepoPool, dir_path: string) {
defer linux.close(fd) defer linux.close(fd)
if has_git_dir(fd) { if has_git_dir(fd) {
cloned, _ := strings.clone(dir_path) cloned := strings.clone(dir_path)
sync.mutex_lock(&pool.results_lock) sync.mutex_lock(&pool.results_lock)
append(pool.results, cloned) append(pool.results, cloned)
sync.mutex_unlock(&pool.results_lock) sync.mutex_unlock(&pool.results_lock)
@@ -126,3 +133,4 @@ process_repo_dir :: proc(pool: ^RepoPool, dir_path: string) {
} }
} }
} }
+5 -9
View File
@@ -37,7 +37,7 @@ create_file :: proc(env: TestEnv, path: string, content: string = "") {
full := join_path(env.temp_dir, path) full := join_path(env.temp_dir, path)
defer delete(full) defer delete(full)
dir_end := strings.last_index(full, "/") dir_end := strings.last_index(full, os.Path_Separator_String)
if dir_end >= 0 { if dir_end >= 0 {
dir_path := full[:dir_end] dir_path := full[:dir_end]
os.mkdir_all(dir_path, os.Permissions_Default_Directory) os.mkdir_all(dir_path, os.Permissions_Default_Directory)
@@ -105,12 +105,7 @@ assert_output :: proc(
} }
} }
assert_output_empty :: proc( assert_output_empty :: proc(t: ^testing.T, env: TestEnv, args: []string, opts: WalkOptions) {
t: ^testing.T,
env: TestEnv,
args: []string,
opts: WalkOptions,
) {
results := collect_results(env, args, opts) results := collect_results(env, args, opts)
defer { defer {
for r in results {delete(r)} for r in results {delete(r)}
@@ -139,10 +134,10 @@ collect_results :: proc(env: TestEnv, args: []string, opts: WalkOptions) -> [dyn
r := results[i] r := results[i]
if strings.has_prefix(r, env.temp_dir) { if strings.has_prefix(r, env.temp_dir) {
stripped := r[len(env.temp_dir):] stripped := r[len(env.temp_dir):]
if len(stripped) > 0 && stripped[0] == '/' { if len(stripped) > 0 && stripped[0] == os.Path_Separator {
stripped = stripped[1:] stripped = stripped[1:]
} }
new_r, _ := strings.clone(stripped) new_r := strings.clone(stripped)
delete(r) delete(r)
results[i] = new_r results[i] = new_r
} }
@@ -150,3 +145,4 @@ collect_results :: proc(env: TestEnv, args: []string, opts: WalkOptions) -> [dyn
return results return results
} }
+16 -6
View File
@@ -78,7 +78,15 @@ collect_worker :: proc(t: ^thread.Thread) {
} }
} }
walk :: proc(roots: []string, results: ^[dynamic]string, opts: WalkOptions, thread_count: int) { walk :: proc(
roots: []string,
results: ^[dynamic]string,
opts: WalkOptions,
thread_count: int,
allocator := context.allocator,
) {
// TODO: This may be a code smell
context.allocator = allocator
if len(roots) == 0 do return if len(roots) == 0 do return
ch, _ := chan.create(chan.Chan([]u8), max(2 * thread_count, 2), context.allocator) ch, _ := chan.create(chan.Chan([]u8), max(2 * thread_count, 2), context.allocator)
@@ -189,7 +197,7 @@ flush_buf :: proc(ch: chan.Chan([]u8), local: ^[dynamic]u8) {
} }
append_path :: proc(buf: ^[dynamic]u8, parent, name: string, trailing_slash: bool) { append_path :: proc(buf: ^[dynamic]u8, parent, name: string, trailing_slash: bool) {
need_sep := len(parent) > 0 && parent[len(parent) - 1] != '/' need_sep := len(parent) > 0 && parent[len(parent) - 1] != os.Path_Separator
size := len(parent) + len(name) + 1 size := len(parent) + len(name) + 1
if need_sep do size += 1 if need_sep do size += 1
if trailing_slash do size += 1 if trailing_slash do size += 1
@@ -200,9 +208,9 @@ append_path :: proc(buf: ^[dynamic]u8, parent, name: string, trailing_slash: boo
pos := old_len pos := old_len
pos += copy(buf[pos:], parent) pos += copy(buf[pos:], parent)
if need_sep {buf[pos] = '/'; pos += 1} if need_sep {buf[pos] = os.Path_Separator; pos += 1}
pos += copy(buf[pos:], name) pos += copy(buf[pos:], name)
if trailing_slash {buf[pos] = '/'; pos += 1} if trailing_slash {buf[pos] = os.Path_Separator; pos += 1}
buf[pos] = '\n' buf[pos] = '\n'
} }
@@ -362,6 +370,7 @@ check_chain :: proc(ctx: ^GIContext, entry_rel: string, is_dir: bool) -> bool {
return false return false
} }
// TODO: Is this a copy of something in the core packages?
relative_to :: proc(entry_rel, base_rel: string) -> string { relative_to :: proc(entry_rel, base_rel: string) -> string {
if len(base_rel) == 0 do return entry_rel if len(base_rel) == 0 do return entry_rel
prefix_len := len(base_rel) prefix_len := len(base_rel)
@@ -442,14 +451,15 @@ load_ignore_patterns :: proc(dir_path: string, in_repo: bool) -> ^Gitignore {
return gi return gi
} }
// TODO: Is this a copy of core package behavior?
join_path :: proc(parent, child: string) -> string { join_path :: proc(parent, child: string) -> string {
need_sep := len(parent) == 0 || parent[len(parent) - 1] != '/' need_sep := len(parent) == 0 || parent[len(parent) - 1] != os.Path_Separator
total := len(parent) + len(child) total := len(parent) + len(child)
if need_sep do total += 1 if need_sep do total += 1
buf := make([]u8, total, context.allocator) buf := make([]u8, total, context.allocator)
pos := copy(buf, parent) pos := copy(buf, parent)
if need_sep { if need_sep {
buf[pos] = '/' buf[pos] = os.Path_Separator
pos += 1 pos += 1
} }
copy(buf[pos:], child) copy(buf[pos:], child)
+134
View File
@@ -0,0 +1,134 @@
package main
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 {
field := reflect.struct_field_at(T, i)
name, _ := strings.replace(field.name, "_", "-", -1, context.temp_allocator)
args_tag := reflect.struct_tag_get(field.tag, "args")
if n, ok := get_subtag(args_tag, "name"); ok {
name = n
}
long_map[name] = field
if s, ok := get_subtag(args_tag, "short"); ok {
short_map[s] = field
}
}
overflow_dyn := make([dynamic]string, 0, len(args), context.temp_allocator)
i := 0
for i < len(args) {
arg := args[i]
if strings.starts_with(arg, "--") {
key := arg[2:]
value := ""
has_value := false
if eq_idx := strings.index(key, "="); eq_idx >= 0 {
value = key[eq_idx + 1:]
key = key[:eq_idx]
has_value = true
}
if field, ok := long_map[key]; ok {
if is_bool_type(field) {
set_field(model, field, "")
i += 1
} else if has_value {
set_field(model, field, value)
i += 1
} else if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
set_field(model, field, args[i + 1])
i += 2
} else {
i += 1
}
} else {
i += 1
}
} else if strings.starts_with(arg, "-") && len(arg) == 2 {
short := arg[1:2]
if field, ok := short_map[short]; ok {
if is_bool_type(field) {
set_field(model, field, "")
i += 1
} else if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
set_field(model, field, args[i + 1])
i += 2
} else {
i += 1
}
} else {
i += 1
}
} else {
append(&overflow_dyn, arg)
i += 1
}
}
return overflow_dyn[:]
}
+11 -1
View File
@@ -66,7 +66,7 @@
packages.default = pkgs.stdenv.mkDerivation rec { packages.default = pkgs.stdenv.mkDerivation rec {
pname = "envr"; pname = "envr";
version = "0.3.0"; version = nixpkgs.lib.trim (builtins.readFile ./version.txt);
src = ./.; src = ./.;
nativeBuildInputs = [ nativeBuildInputs = [
@@ -75,6 +75,8 @@
]; ];
buildInputs = [ buildInputs = [
pkgs.git
pkgs.libsodium pkgs.libsodium
mysqlite mysqlite
]; ];
@@ -89,6 +91,11 @@
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
echo '${version}' > version.txt echo '${version}' > version.txt
# Generate man pages before main build (binary gets clobbered)
odin build . -define:GENDOCS=true -out:gendocs
./gendocs --man
odin build . -o:speed -out:${pname} odin build . -o:speed -out:${pname}
runHook postBuild runHook postBuild
''; '';
@@ -96,6 +103,9 @@
installPhase = '' installPhase = ''
runHook preInstall runHook preInstall
install -Dm755 ${pname} $out/bin/${pname} install -Dm755 ${pname} $out/bin/${pname}
mkdir -p $out/share/man/man1
cp docs/man/*.1 $out/share/man/man1/
runHook postInstall runHook postInstall
''; '';
}; };
+289
View File
@@ -0,0 +1,289 @@
package main
import "core:fmt"
import "core:os"
import "core:strings"
import "core:time"
when #config(GENDOCS, false) {
main :: proc() {
args := os.args
if len(args) < 2 {
fmt.eprintln("Usage: gendocs --man | --md")
os.exit(1)
}
switch args[1] {
case "--man":
generate_man_pages()
case "--md":
generate_markdown()
case:
fmt.eprintf("Unknown format: %s\n", args[1])
os.exit(1)
}
}
}
generate_man_pages :: proc() {
os.mkdir_all("docs/man")
now := time.now()
month_year := fmt.tprintf("%s %d", time.month(now), time.year(now))
generate_man_main(month_year)
for &c in COMMANDS {
generate_man_command(&c, month_year)
}
fmt.println("Generated man pages in docs/man/")
}
generate_man_main :: proc(month_year: string) {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
fmt.sbprintf(&sb, ".TH ENVR 1 \"%s\" \"envr\" \"envr Manual\"\n", month_year)
fmt.sbprintf(&sb, ".SH NAME\nenvr \\- keep your .env files synced to an encrypted database\n")
fmt.sbprintf(&sb, ".SH SYNOPSIS\n\\fBenvr\\fR [\\fIcommand\\fR] [\\fIflags\\fR]\n")
fmt.sbprintf(
&sb,
".SH DESCRIPTION\nenvr keeps your .env synced to a local, encrypted database.\n",
)
fmt.sbprintf(
&sb,
"Is a safe and easy way to gather all your .env files in one place where they can\n",
)
fmt.sbprintf(&sb, "easily be backed by another tool such as restic or git.\n")
fmt.sbprintf(&sb, ".PP\nAll your data is stored in ~/.envr/data.envr\n")
fmt.sbprintf(&sb, ".SH COMMANDS\n")
for c in COMMANDS {
fmt.sbprintf(&sb, ".TP\n\\fB%s\\fR\n%s\n", c.name, c.short)
for a in c.aliases {
fmt.sbprintf(&sb, ".TP\n\\fB%s\\fR (alias for %s)\n%s\n", a, c.name, c.short)
}
}
fmt.sbprintf(&sb, ".SH GLOBAL OPTIONS\n")
for ft in Flag_Type {
if ft not_in GLOBAL_FLAGS do continue
write_man_flag(&sb, ft)
}
fmt.sbprintf(&sb, ".SH SEE ALSO\n")
for c, i in COMMANDS {
if i > 0 do fmt.sbprintf(&sb, ",\n")
fmt.sbprintf(&sb, ".BR envr-%s (1)", c.name)
}
fmt.sbprintf(&sb, "\n")
path := "docs/man/envr.1"
err := os.write_entire_file(path, transmute([]u8)strings.to_string(sb))
if err != nil {
fmt.eprintf("Error writing %s: %v\n", path, err)
}
}
generate_man_command :: proc(c: ^CommandInfo, month_year: string) {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
upper_name := strings.to_upper(fmt.tprintf("envr-%s", c.name))
fmt.sbprintf(&sb, ".TH %s 1 \"%s\" \"envr\" \"envr Manual\"\n", upper_name, month_year)
fmt.sbprintf(&sb, ".SH NAME\nenvr-%s \\- %s\n", c.name, c.short)
fmt.sbprintf(&sb, ".SH SYNOPSIS\n\\fB%s\\fR", c.usage)
has_positionals := false
for arg in c.args {
has_positionals = true
if arg.optional {
fmt.sbprintf(&sb, " [\\fI%s\\fR]", arg.name)
} else {
fmt.sbprintf(&sb, " \\fI%s\\fR", arg.name)
}
}
fmt.sbprintf(&sb, "\n")
if len(c.long) > 0 {
fmt.sbprintf(&sb, ".SH DESCRIPTION\n%s\n", c.long)
}
if has_positionals {
fmt.sbprintf(&sb, ".SH ARGUMENTS\n")
for arg in c.args {
fmt.sbprintf(&sb, ".TP\n\\fB%s\\fR\n%s\n", arg.name, arg.desc)
}
}
fmt.sbprintf(&sb, ".SH OPTIONS\n")
for ft in Flag_Type {
if ft not_in c.flags do continue
write_man_flag(&sb, ft)
}
path := fmt.tprintf("docs/man/envr-%s.1", c.name)
err := os.write_entire_file(path, transmute([]u8)strings.to_string(sb))
if err != nil {
fmt.eprintf("Error writing %s: %v\n", path, err)
}
}
write_man_flag :: proc(sb: ^strings.Builder, ft: Flag_Type) {
f := flag_field(ft)
fmt.sbprintf(sb, ".TP\n")
if len(f.short_name) > 0 {
fmt.sbprintf(sb, "\\fB-%s\\fR, \\fB--%s\\fR", f.short_name, f.long_name)
} else {
fmt.sbprintf(sb, "\\fB--%s\\fR", f.long_name)
}
#partial switch f.kind {
case .String:
fmt.sbprintf(sb, " \\fIvalue\\fR")
case .Enum:
values, _ := strings.replace(f.enum_values, "'", "", -1)
fmt.sbprintf(sb, " \\fI%s\\fR", values)
}
fmt.sbprintf(sb, "\n%s\n", f.usage)
if len(f.default_val) > 0 {
#partial switch f.kind {
case .String:
fmt.sbprintf(sb, "(default \"%s\")\n", f.default_val)
case .Enum:
fmt.sbprintf(sb, "(default '%s')\n", f.default_val)
}
}
}
generate_markdown :: proc() {
os.mkdir_all("docs/cli")
generate_md_main()
for &c in COMMANDS {
generate_md_command(&c)
}
fmt.println("Generated markdown in docs/cli/")
}
generate_md_main :: proc() {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
fmt.sbprintf(&sb, "## envr\n\n")
fmt.sbprintf(&sb, "Manage your .env files.\n\n")
fmt.sbprintf(&sb, "### Synopsis\n\n")
fmt.sbprint(&sb, ENVR_DESCRIPTION)
fmt.sbprintf(&sb, "\n\n### Options\n\n")
write_md_options(&sb, {.Help})
fmt.sbprintf(&sb, "\n### SEE ALSO\n\n")
for c in COMMANDS {
fmt.sbprintf(&sb, "* [envr %s](envr_%s.md)\t - %s\n", c.name, c.name, c.short)
}
fmt.sbprintf(&sb, "\n")
write_file("docs/cli/envr.md", strings.to_string(sb))
}
generate_md_command :: proc(c: ^CommandInfo) {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
fmt.sbprintf(&sb, "## envr %s\n\n", c.name)
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")
}
fmt.sbprintf(&sb, "```\n%s [flags]\n```\n\n", c.usage)
if len(c.args) > 0 {
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)
}
fmt.sbprintf(&sb, "\n")
}
fmt.sbprintf(&sb, "### Options\n\n")
write_md_options(&sb, c.flags)
for a in c.aliases {
fmt.sbprintf(&sb, "\n*Alias: `%s`*\n", a)
}
fmt.sbprintf(&sb, "\n### SEE ALSO\n\n")
fmt.sbprintf(&sb, "* [envr](envr.md)\t - Manage your .env files.\n")
path := fmt.tprintf("docs/cli/envr_%s.md", c.name)
write_file(path, strings.to_string(sb))
}
write_md_options :: proc(sb: ^strings.Builder, flags: bit_set[Flag_Type]) {
displays := make([dynamic]string, 0, 5, context.temp_allocator)
defer delete(displays)
descs := make([dynamic]string, 0, 5, context.temp_allocator)
defer delete(descs)
max_width := 0
for ft in Flag_Type {
if ft not_in flags do continue
f := flag_field(ft)
display: string
if len(f.short_name) > 0 {
display = fmt.tprintf("-%s, --%s", f.short_name, f.long_name)
} else {
display = fmt.tprintf(" --%s", f.long_name)
}
desc := f.usage
if len(f.default_val) > 0 {
#partial switch f.kind {
case .String:
desc = fmt.tprintf(`%s (default "%s")`, f.usage, f.default_val)
case .Enum:
desc = fmt.tprintf("%s (default '%s')", f.usage, f.default_val)
}
}
append(&displays, display)
append(&descs, desc)
if len(display) > max_width {
max_width = len(display)
}
}
fmt.sbprintf(sb, "```\n")
pad_buf: [64]u8
for i in 0..<len(displays) {
padding := max_width - len(displays[i]) + 3
for j in 0..<padding {
pad_buf[j] = ' '
}
fmt.sbprintf(sb, " %s%s%s\n", displays[i], string(pad_buf[:padding]), descs[i])
}
fmt.sbprintf(sb, "```\n")
}
write_file :: proc(path: string, content: string) {
err := os.write_entire_file(path, transmute([]u8)content)
if err != nil {
fmt.eprintf("Error writing %s: %v\n", path, err)
}
}
+100
View File
@@ -0,0 +1,100 @@
_envr() {
local cur prev cmd
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
cmd="${COMP_WORDS[1]}"
if [[ $COMP_CWORD -eq 1 ]]; then
COMPREPLY=( $(compgen -W "init scan sync backup add restore list remove check version edit-config completion" -- "$cur") )
return
fi
case "$prev" in
--config-file|-c)
COMPREPLY=( $(compgen -f -- "$cur") )
return
;;
--output|-o)
COMPREPLY=( $(compgen -W "auto table json" -- "$cur") )
return
;;
--color)
COMPREPLY=( $(compgen -W "auto always never" -- "$cur") )
return
;;
esac
case "$cur" in -*)
case "$cmd" in
init)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color --force -f" -- "$cur") )
return
;;
scan)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
sync)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --output -o --color" -- "$cur") )
return
;;
backup|add)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
restore)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
list)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --output -o --color" -- "$cur") )
return
;;
remove)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
check)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
version)
COMPREPLY=( $(compgen -W "--help -h" -- "$cur") )
return
;;
edit-config)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
completion)
COMPREPLY=( $(compgen -W "--help -h" -- "$cur") )
return
;;
esac
;;
esac
case "$cmd" in
backup|add)
COMPREPLY=( $(compgen -W "$(envr scan --output json 2>/dev/null)" -- "$cur") )
return
;;
restore)
COMPREPLY=( $(compgen -W "$(envr list --output json 2>/dev/null)" -- "$cur") )
return
;;
remove)
COMPREPLY=( $(compgen -W "$(envr list --output json 2>/dev/null)" -- "$cur") )
return
;;
completion)
COMPREPLY=( $(compgen -W "nushell bash" -- "$cur") )
return
;;
esac
}
complete -F _envr envr
+72 -68
View File
@@ -14,77 +14,81 @@ when SPALL {
spall_buffer: spall.Buffer spall_buffer: spall.Buffer
} }
main :: proc() { when !#config(GENDOCS, false) {
when SPALL { main :: proc() {
ctx, spall_ok := spall.context_create_with_scale("envr.spall", false, 1.0) when SPALL {
if !spall_ok { ctx, spall_ok := spall.context_create_with_scale("envr.spall", false, 1.0)
fmt.eprintln("Failed to create spall trace file") if !spall_ok {
fmt.eprintln("Failed to create spall trace file")
os.exit(1)
}
spall_ctx = ctx
defer spall.context_destroy(&spall_ctx)
spall_backing := make([]u8, spall.BUFFER_DEFAULT_SIZE)
defer delete(spall_backing)
spall_buffer = spall.buffer_create(spall_backing, u32(sync.current_thread_id()))
defer spall.buffer_destroy(&spall_ctx, &spall_buffer)
}
when ODIN_DEBUG {
heap_track: mem.Tracking_Allocator
mem.tracking_allocator_init(&heap_track, context.allocator)
defer mem.tracking_allocator_destroy(&heap_track)
defer if len(heap_track.allocation_map) > 0 {
for _, leak in heap_track.allocation_map {
fmt.eprintf("LEAK: %v leaked %m\n", leak.location, leak.size)
}
}
context.allocator = mem.tracking_allocator(&heap_track)
temp_track: mem.Tracking_Allocator
mem.tracking_allocator_init(&temp_track, context.temp_allocator)
defer mem.tracking_allocator_destroy(&temp_track)
context.temp_allocator = mem.tracking_allocator(&temp_track)
}
defer free_all(context.temp_allocator)
cmd, ok := parse_args(os.args, os.to_writer(os.stdout), os.to_writer(os.stderr))
defer delete_command(&cmd) // delete flushes automatically
if !ok {
return
}
switch cmd.name {
case "init":
cmd_init(&cmd)
case "version":
cmd_version(&cmd)
case "list":
cmd_list(&cmd)
case "backup", "add":
cmd_backup(&cmd)
case "remove":
cmd_remove(&cmd)
case "restore":
cmd_restore(&cmd)
case "edit-config":
cmd_edit_config(&cmd)
case "check":
cmd_check(&cmd)
case "scan":
cmd_scan(&cmd)
case "sync":
cmd_sync(&cmd)
case "completion":
cmd_completion(&cmd)
case "uninstall":
cmd_uninstall(&cmd)
case:
fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
write_usage(cmd.out)
os.exit(1) os.exit(1)
} }
spall_ctx = ctx
defer spall.context_destroy(&spall_ctx)
spall_backing := make([]u8, spall.BUFFER_DEFAULT_SIZE)
defer delete(spall_backing)
spall_buffer = spall.buffer_create(spall_backing, u32(sync.current_thread_id()))
defer spall.buffer_destroy(&spall_ctx, &spall_buffer)
} }
} // !GENDOCS
when ODIN_DEBUG {
heap_track: mem.Tracking_Allocator
mem.tracking_allocator_init(&heap_track, context.allocator)
defer mem.tracking_allocator_destroy(&heap_track)
defer if len(heap_track.allocation_map) > 0 {
for _, leak in heap_track.allocation_map {
fmt.eprintf("LEAK: %v leaked %m\n", leak.location, leak.size)
}
}
context.allocator = mem.tracking_allocator(&heap_track)
temp_track: mem.Tracking_Allocator
mem.tracking_allocator_init(&temp_track, context.temp_allocator)
defer mem.tracking_allocator_destroy(&temp_track)
context.temp_allocator = mem.tracking_allocator(&temp_track)
}
defer free_all(context.temp_allocator)
cmd, ok := parse_args(os.args, os.to_writer(os.stdout), os.to_writer(os.stderr))
defer delete_command(&cmd) // delete flushes automatically
if !ok {
return
}
switch cmd.name {
case "init":
cmd_init(&cmd)
case "version":
cmd_version(&cmd)
case "list":
cmd_list(&cmd)
case "backup", "add":
cmd_backup(&cmd)
case "remove":
cmd_remove(&cmd)
case "restore":
cmd_restore(&cmd)
case "edit-config":
cmd_edit_config(&cmd)
case "check":
cmd_check(&cmd)
case "scan":
cmd_scan(&cmd)
case "sync":
cmd_sync(&cmd)
case "nushell-completion":
cmd_nushell_completion(&cmd)
case:
fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
write_usage(cmd.out)
os.exit(1)
}
}
when SPALL { when SPALL {
@(instrumentation_enter) @(instrumentation_enter)
-71
View File
@@ -1,71 +0,0 @@
# envr command extern definitions for Nushell
# A tool for managing environment files and backups
export def tracked-paths [] {
(
^envr list
| from json
| each {
[$in.directory $in.path] | path join
}
)
}
export def untracked-paths [] {
(
^envr scan
| from json
)
}
export extern envr [
...args: any
--help(-h) # Show help information
]
export extern "envr backup" [
--help(-h) # Show help for backup command
path: path@untracked-paths # Path to .env file to backup
]
export extern "envr check" [
--help(-h) # Show help for check command
]
export extern "envr edit-config" [
--help(-h) # Show help for edit-config command
]
export extern "envr help" [
command?: string # Show help for specific command
]
export extern "envr init" [
--help(-h) # Show help for init command
]
export extern "envr list" [
--help(-h) # Show help for list command
]
export extern "envr remove" [
--help(-h) # Show help for remove command
path: path@tracked-paths
]
export extern "envr restore" [
--help(-h) # Show help for restore command
path: path@tracked-paths
]
export extern "envr scan" [
--help(-h) # Show help for scan command
]
export extern "envr sync" [
--help(-h) # Show help for sync command
]
export extern "envr nushell-completion" [
--help(-h) # Show help for nushell-completion command
]
+92 -28
View File
@@ -1,6 +1,7 @@
package main package main
import "core:fmt" import "core:fmt"
import "core:strings"
import "core:sys/posix" import "core:sys/posix"
import "core:terminal/ansi" import "core:terminal/ansi"
@@ -15,6 +16,8 @@ Key :: enum {
Space, Space,
Enter, Enter,
Escape, Escape,
Backspace,
Char,
Unknown, Unknown,
} }
@@ -38,11 +41,17 @@ multi_select :: proc(
} }
selected = make([dynamic]bool, len(options)) selected = make([dynamic]bool, len(options))
filter: [dynamic]u8
defer delete(filter)
filtered: [dynamic]int
defer delete(filtered)
rebuild_filtered(options, filter[:], &filtered)
cursor: int = 0 cursor: int = 0
scroll_offset: int = 0 scroll_offset: int = 0
fmt.printf(ansi.CSI + ansi.DECTCEM_HIDE) fmt.printf(ansi.CSI + ansi.DECTCEM_HIDE)
visible := render_options(prompt, options, selected[:], cursor, scroll_offset) visible := render_options(prompt, options, selected[:], filtered[:], string(filter[:]), cursor, scroll_offset)
raw, ok := enable_raw_mode(posix.STDIN_FILENO) raw, ok := enable_raw_mode(posix.STDIN_FILENO)
if !ok { if !ok {
@@ -52,33 +61,63 @@ multi_select :: proc(
defer disable_raw_mode(&raw) defer disable_raw_mode(&raw)
for { for {
key := read_key() key, ch := read_key()
switch 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: case .Up:
if cursor > 0 { if cursor > 0 {
cursor -= 1 cursor -= 1
} }
case .Down: case .Down:
if cursor < len(options) - 1 { if cursor < len(filtered) - 1 {
cursor += 1 cursor += 1
} }
case .Space: case .Space:
selected[cursor] = !selected[cursor] if len(filtered) > 0 {
selected[filtered[cursor]] = !selected[filtered[cursor]]
}
case .Enter: case .Enter:
fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, visible + 1) fmt.printf(
ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW,
visible + 1,
)
result = .Confirm result = .Confirm
return return
case .Escape: case .Escape:
fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, visible + 1) fmt.printf(
ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW,
visible + 1,
)
result = .Cancel result = .Cancel
return return
case .Unknown: case .Unknown:
} }
scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(options) - MAX_VISIBLE)) scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(filtered) - MAX_VISIBLE))
fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.RESET + ansi.ED, visible + 1) fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.RESET + ansi.ED, visible + 1)
visible = render_options(prompt, options, selected[:], cursor, scroll_offset) visible = render_options(prompt, options, selected[:], filtered[:], string(filter[:]), cursor, scroll_offset)
}
}
rebuild_filtered :: proc(options: []string, filter: []u8, filtered: ^[dynamic]int) {
clear(filtered)
filter_str := string(filter)
filter_lower := strings.to_lower(filter_str, context.temp_allocator)
for opt, i in options {
opt_lower := strings.to_lower(opt, context.temp_allocator)
if strings.contains(opt_lower, filter_lower) {
append(filtered, i)
}
} }
} }
@@ -86,29 +125,49 @@ render_options :: proc(
prompt: string, prompt: string,
options: []string, options: []string,
selected: []bool, selected: []bool,
filtered: []int,
filter_text: string,
cursor: int, cursor: int,
scroll_offset: int, scroll_offset: int,
) -> int { ) -> int {
fmt.printf(ansi.CSI + ansi.BOLD + ";" + ansi.FG_CYAN + ansi.SGR + "%s" + ANSI_RESET + " (↑/↓ move, space select, enter confirm)\r\n", prompt) fmt.printf(
"%s (type to filter, ↑/↓ move, space select, enter confirm)\r\n",
colorize(.Option_Label, prompt),
)
fmt.printf("filter: %s\r\n", filter_text)
line_count := 1
if len(filtered) == 0 {
fmt.printf(" No matches\r\n")
return line_count + 1
}
end := scroll_offset + MAX_VISIBLE end := scroll_offset + MAX_VISIBLE
if end > len(options) { if end > len(filtered) {
end = len(options) end = len(filtered)
} }
for i in scroll_offset ..< end { for i in scroll_offset ..< end {
original := filtered[i]
checkbox := " " checkbox := " "
if selected[i] { if selected[original] {
checkbox = "x" checkbox = "x"
} }
if i == cursor { if i == cursor {
fmt.printf(ansi.CSI + ansi.BOLD + ";" + ansi.FG_GREEN + ansi.SGR + "> " + ANSI_RESET + "[" + ansi.CSI + ansi.FG_GREEN + ansi.SGR + "%s" + ANSI_RESET + "] %s\r\n", checkbox, options[i]) fmt.printf(
"%s [%s] %s\r\n",
colorize(.Caret, ">"),
colorize(.Sucess, checkbox),
options[original],
)
} else { } else {
fmt.printf(" [" + ansi.CSI + ansi.FAINT + ansi.SGR + "%s" + ANSI_RESET + "] %s\r\n", checkbox, options[i]) fmt.printf(" [%s] %s\r\n", colorize(.Sucess, checkbox), options[original])
} }
} }
return end - scroll_offset return line_count + (end - scroll_offset)
} }
enable_raw_mode :: proc(fd: posix.FD) -> (Raw_State, bool) { enable_raw_mode :: proc(fd: posix.FD) -> (Raw_State, bool) {
@@ -138,21 +197,23 @@ disable_raw_mode :: proc(state: ^Raw_State) {
posix.tcsetattr(state.fd, .TCSAFLUSH, &state.original) posix.tcsetattr(state.fd, .TCSAFLUSH, &state.original)
} }
read_key :: proc() -> Key { read_key :: proc() -> (key: Key, ch: rune) {
buf: [3]u8 buf: [3]u8
n := posix.read(posix.STDIN_FILENO, &buf[0], 1) n := posix.read(posix.STDIN_FILENO, &buf[0], 1)
if n <= 0 { if n <= 0 {
return .Unknown return .Unknown, 0
} }
switch buf[0] { switch buf[0] {
case ' ': case ' ':
return .Space return .Space, 0
case '\n', '\r': case '\n', '\r':
return .Enter return .Enter, 0
case 0x03: case 0x03:
return .Escape return .Escape, 0
case 0x08, 0x7F:
return .Backspace, 0
case 0x1b: case 0x1b:
tv: posix.timeval tv: posix.timeval
tv.tv_sec = 0 tv.tv_sec = 0
@@ -164,12 +225,12 @@ read_key :: proc() -> Key {
ready := posix.select(1, &set, nil, nil, &tv) ready := posix.select(1, &set, nil, nil, &tv)
if ready <= 0 { if ready <= 0 {
return .Escape return .Escape, 0
} }
n2 := posix.read(posix.STDIN_FILENO, &buf[1], 1) n2 := posix.read(posix.STDIN_FILENO, &buf[1], 1)
if n2 <= 0 || buf[1] != '[' { if n2 <= 0 || buf[1] != '[' {
return .Escape return .Escape, 0
} }
posix.FD_ZERO(&set) posix.FD_ZERO(&set)
@@ -179,24 +240,27 @@ read_key :: proc() -> Key {
ready = posix.select(1, &set, nil, nil, &tv) ready = posix.select(1, &set, nil, nil, &tv)
if ready <= 0 { if ready <= 0 {
return .Escape return .Escape, 0
} }
n3 := posix.read(posix.STDIN_FILENO, &buf[2], 1) n3 := posix.read(posix.STDIN_FILENO, &buf[2], 1)
if n3 <= 0 { if n3 <= 0 {
return .Escape return .Escape, 0
} }
switch buf[2] { switch buf[2] {
case 'A': case 'A':
return .Up return .Up, 0
case 'B': case 'B':
return .Down return .Down, 0
case: case:
return .Escape return .Escape, 0
} }
case: case:
return .Unknown if buf[0] >= 0x20 && buf[0] <= 0x7E {
return .Char, rune(buf[0])
}
return .Unknown, 0
} }
} }
+1 -1
View File
@@ -83,6 +83,6 @@ test_scan_path_empty_dir :: proc(t: ^testing.T) {
results, ok := scan_path(base, cfg) results, ok := scan_path(base, cfg)
defer delete(results) defer delete(results)
testing.expect(t, ok, "scan_path should succeed") testing.expect(t, ok, "scan_path should succeed")
testing.expect(t, len(results) == 0, fmt.tprintf("expected 0 results, got %d", len(results))) testing.expect_value(t, len(results), 0)
} }
+7 -1
View File
@@ -17,6 +17,12 @@ DESERIALIZE_FLAGS :: bit_set[DESERIALIZE_FLAG]
DESERIALIZE_FLAG :: enum u32 { DESERIALIZE_FLAG :: enum u32 {
FREEONCLOSE = 1, FREEONCLOSE = 1,
RESIZEABLE = 2, RESIZEABLE = 2,
READONLY = 4,
}
SERIALIZE_FLAGS :: bit_set[SERIALIZE_FLAG]
SERIALIZE_FLAG :: enum u32 {
NOCOPY = 1,
} }
foreign lib { foreign lib {
@@ -43,7 +49,7 @@ foreign lib {
@(link_name = "sqlite3_changes") @(link_name = "sqlite3_changes")
changes :: proc(db: Db) -> c.int --- changes :: proc(db: Db) -> c.int ---
@(link_name = "sqlite3_serialize") @(link_name = "sqlite3_serialize")
serialize :: proc(db: Db, zSchema: cstring, piSize: ^i64, mFlags: u32) -> [^]u8 --- serialize :: proc(db: Db, zSchema: cstring, piSize: ^i64, mFlags: SERIALIZE_FLAGS) -> [^]u8 ---
@(link_name = "sqlite3_deserialize") @(link_name = "sqlite3_deserialize")
deserialize :: proc(db: Db, zSchema: cstring, pData: [^]u8, szDb: i64, szBuf: i64, mFlags: DESERIALIZE_FLAGS) -> c.int --- deserialize :: proc(db: Db, zSchema: cstring, pData: [^]u8, szDb: i64, szBuf: i64, mFlags: DESERIALIZE_FLAGS) -> c.int ---
@(link_name = "sqlite3_malloc64") @(link_name = "sqlite3_malloc64")
+42 -51
View File
@@ -35,18 +35,18 @@ parse_ssh_public_key :: proc(pub_path: string) -> (pub: [32]u8, ok: bool) {
return return
} }
offset := 0 rest := decoded
key_type, type_ok := read_wire_string(decoded, &offset) key_type, type_ok := read_wire_string(&rest)
if !type_ok || key_type != SSH_ED25519 { if !type_ok || string(key_type) != SSH_ED25519 {
return return
} }
pk_data, pk_ok := read_wire_string(decoded, &offset) pk_data, pk_ok := read_wire_string(&rest)
if !pk_ok || len(pk_data) != 32 { if !pk_ok || len(pk_data) != 32 {
return return
} }
mem.copy_non_overlapping(&pub[0], raw_data(pk_data), 32) mem.copy_non_overlapping(&pub[0], &pk_data[0], 32)
ok = true ok = true
return return
@@ -91,81 +91,63 @@ parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: boo
return return
} }
offset := len(magic) rest := decoded[len(magic):]
ciphername, cipher_ok := read_wire_string(decoded, &offset) ciphername, cipher_ok := read_wire_string(&rest)
if !cipher_ok || ciphername != "none" { if !cipher_ok || string(ciphername) != "none" {
return return
} }
kdfname, kdf_ok := read_wire_string(decoded, &offset) kdfname, kdf_ok := read_wire_string(&rest)
if !kdf_ok || kdfname != "none" { if !kdf_ok || string(kdfname) != "none" {
return return
} }
_, opts_ok := read_wire_string(decoded, &offset) _, opts_ok := read_wire_string(&rest)
if !opts_ok { if !opts_ok {
return return
} }
if offset + 4 > len(decoded) { num_keys, nkeys_ok := read_wire_u32(&rest)
if !nkeys_ok || num_keys != 1 {
return return
} }
num_keys := endian.get_u32(decoded[offset:offset + 4], .Big) or_return _, pub_blob_ok := read_wire_string(&rest)
offset += 4
if num_keys != 1 {
return
}
_, pub_blob_ok := read_wire_string(decoded, &offset)
if !pub_blob_ok { if !pub_blob_ok {
return return
} }
priv_blob, priv_blob_ok := read_wire_string(decoded, &offset) priv_blob, priv_blob_ok := read_wire_string(&rest)
if !priv_blob_ok { if !priv_blob_ok {
return return
} }
inner_offset := 0 inner := priv_blob
if inner_offset + 8 > len(priv_blob) {
check1, c1_ok := read_wire_u32(&inner)
check2, c2_ok := read_wire_u32(&inner)
if !c1_ok || !c2_ok || check1 != check2 {
return return
} }
check1 := endian.get_u32( priv_type, type_ok := read_wire_string(&inner)
transmute([]u8)(priv_blob)[inner_offset:inner_offset + 4], if !type_ok || string(priv_type) != SSH_ED25519 {
.Big,
) or_return
inner_offset += 4
check2 := endian.get_u32(
transmute([]u8)(priv_blob)[inner_offset:inner_offset + 4],
.Big,
) or_return
inner_offset += 4
if check1 != check2 {
return return
} }
priv_type, type_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset) pub_wire, pub_ok := read_wire_string(&inner)
if !type_ok || priv_type != SSH_ED25519 {
return
}
pub_wire, pub_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
if !pub_ok || len(pub_wire) != 32 { if !pub_ok || len(pub_wire) != 32 {
return return
} }
mem.copy_non_overlapping(&kp.Public[0], raw_data(pub_wire), 32) mem.copy_non_overlapping(&kp.Public[0], &pub_wire[0], 32)
priv_wire, priv_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset) priv_wire, priv_ok := read_wire_string(&inner)
if !priv_ok || len(priv_wire) != 64 { if !priv_ok || len(priv_wire) != 64 {
return return
} }
mem.copy_non_overlapping(&kp.Private[0], raw_data(priv_wire), 32) mem.copy_non_overlapping(&kp.Private[0], &priv_wire[0], 32)
ok = true ok = true
return return
@@ -182,19 +164,28 @@ is_ed25519_key :: proc(
return ok, nil return ok, nil
} }
read_wire_string :: proc(data: []u8, offset: ^int) -> (s: string, ok: bool) { read_wire_string :: proc(data: ^[]u8) -> (s: []u8, ok: bool) #no_bounds_check {
if offset^ + 4 > len(data) { if len(data^) < 4 {
return return
} }
length := endian.get_u32(data[offset^:offset^ + 4], .Big) or_return length := endian.get_u32(data^[:4], .Big) or_return
offset^ += 4 data^ = data^[4:]
if offset^ + int(length) > len(data) { if len(data^) < int(length) {
return return
} }
s = data^[:int(length)]
data^ = data^[int(length):]
ok = true
return
}
s = string(data[offset^:offset^ + int(length)]) read_wire_u32 :: proc(data: ^[]u8) -> (v: u32, ok: bool) #no_bounds_check {
offset^ += int(length) if len(data^) < 4 {
return
}
v = endian.get_u32(data^[:4], .Big) or_return
data^ = data^[4:]
ok = true ok = true
return return
} }
+7 -16
View File
@@ -46,30 +46,21 @@ test_private_key_pub_matches_public_key :: proc(t: ^testing.T) {
kp, priv_ok := parse_ssh_private_key(TEST_KEY_DIR + "/test_ed25519") kp, priv_ok := parse_ssh_private_key(TEST_KEY_DIR + "/test_ed25519")
testing.expect(t, priv_ok, "expected private key to parse") testing.expect(t, priv_ok, "expected private key to parse")
testing.expect( testing.expect_value(t, pub_from_pub, kp.Public)
t,
pub_from_pub == kp.Public,
fmt.tprintf(
"public key mismatch:\n from .pub: %v\n from priv: %v",
pub_from_pub,
kp.Public,
),
)
} }
@(test) @(test)
test_read_wire_string :: proc(t: ^testing.T) { test_read_wire_string :: proc(t: ^testing.T) {
data := []u8{0, 0, 0, 5, u8('h'), u8('e'), u8('l'), u8('l'), u8('o'), 0, 0, 0, 0} data := []u8{0, 0, 0, 5, u8('h'), u8('e'), u8('l'), u8('l'), u8('o'), 0, 0, 0, 0}
offset := 0
s, ok := read_wire_string(data, &offset) buf := data
s, ok := read_wire_string(&buf)
testing.expect(t, ok, "expected read_wire_string to succeed") testing.expect(t, ok, "expected read_wire_string to succeed")
testing.expect(t, s == "hello", fmt.tprintf("expected 'hello', got %q", s)) testing.expect_value(t, string(s), "hello")
testing.expect(t, offset == 9, fmt.tprintf("expected offset 9, got %d", offset)) testing.expect_value(t, len(data) - len(buf), 9)
s2, ok2 := read_wire_string(data, &offset) s2, ok2 := read_wire_string(&buf)
testing.expect(t, ok2, "expected second read to succeed") testing.expect(t, ok2, "expected second read to succeed")
testing.expect(t, s2 == "", "expected empty string") testing.expect_value(t, len(s2), 0)
} }
+6 -10
View File
@@ -3,7 +3,6 @@ package main
import "core:fmt" import "core:fmt"
import "core:io" import "core:io"
import "core:text/table" import "core:text/table"
import "core:unicode/utf8"
decorations := table.Decorations { decorations := table.Decorations {
"┌", "┌",
@@ -19,20 +18,17 @@ decorations := table.Decorations {
"─", "─",
} }
// TODO: Optimize ansi_aware_width ansi_aware_width :: proc(str: string) -> int #no_bounds_check {
ansi_aware_width :: proc(str: string) -> int { width := 0
buf: [4096]byte for i := 0; i < len(str); {
pos := 0
i := 0
for i < len(str) {
if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' { if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' {
i += 2 i += 2
for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}} for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}}
} else { } else {
buf[pos] = str[i]; pos += 1; i += 1 width += 1
i += 1
} }
} }
_, _, width := utf8.grapheme_count(string(buf[:pos]))
return width return width
} }
@@ -46,7 +42,7 @@ write_borderless_table :: proc(w: io.Writer, t: ^table.Table) {
if t.caption != "" { if t.caption != "" {
table.write_text_align( table.write_text_align(
w, w,
fmt.tprintf("%s%s%s", COLOR_HEADINGS, t.caption, ANSI_RESET), colorize(.Heading, t.caption),
.Left, .Left,
0, //t.lpad, 0, //t.lpad,
0, //t.rpad, 0, //t.rpad,
+14 -5
View File
@@ -2,6 +2,7 @@
package main package main
import "core:strings"
import "core:testing" import "core:testing"
@(test) @(test)
@@ -16,18 +17,26 @@ test_ansi_aware_width_empty :: proc(t: ^testing.T) {
@(test) @(test)
test_ansi_aware_width_with_color_codes :: proc(t: ^testing.T) { test_ansi_aware_width_with_color_codes :: proc(t: ^testing.T) {
colored := COLOR_TABLE_HEADING + "Directory" + ANSI_RESET colored := colorize(.Heading, "Directory", disable = false)
testing.expect_value(t, ansi_aware_width(colored), 9) testing.expect_value(t, ansi_aware_width(colored), 9)
} }
@(test) @(test)
test_ansi_aware_width_unicode :: proc(t: ^testing.T) { test_ansi_aware_width_multibyte :: proc(t: ^testing.T) {
testing.expect_value(t, ansi_aware_width("\u2713 Available"), 11) testing.expect_value(t, ansi_aware_width("\u2713 Available"), 13)
testing.expect_value(t, ansi_aware_width("\u2717 Missing"), 9) testing.expect_value(t, ansi_aware_width("\u2717 Missing"), 11)
} }
@(test) @(test)
test_ansi_aware_width_multiple_escape_sequences :: proc(t: ^testing.T) { test_ansi_aware_width_multiple_escape_sequences :: proc(t: ^testing.T) {
colored := COLOR_TABLE_HEADING + "a" + ANSI_RESET + "b" + COLOR_TABLE_HEADING + "c" + ANSI_RESET colored := strings.concatenate(
{
colorize(.Heading, "a", disable = false),
colorize(.Heading, "b", disable = false),
colorize(.Heading, "c", disable = false),
},
context.temp_allocator,
)
testing.expect_value(t, ansi_aware_width(colored), 3) testing.expect_value(t, ansi_aware_width(colored), 3)
} }
+1 -1
View File
@@ -1 +1 @@
0.3.0 1.0.0