12 Commits

Author SHA1 Message Date
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
18 changed files with 463 additions and 325 deletions
+2
View File
@@ -1 +1,3 @@
use flake
export PATH="./:$PATH"
+1 -1
View File
@@ -2,7 +2,7 @@
# Builds release artifacts for GitHub releases
APP_NAME := envr
VERSION := $(shell grep 'version = ' flake.nix | head -1 | sed 's/.*version = "\(.*\)";/\1/')
VERSION := $(file < version.txt)
BUILD_DIR := builds
# Binary names
+4 -12
View File
@@ -8,23 +8,15 @@
4. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
5. Test all cmds / terminal branches.
5. Test all command branches.
6. Generate md and man pages again.
7. Shell completion
7. Bash Shell completion
8. Add tests for untested commands.
9. Update `read_wire_string` to use a slice.
10. Pass allocator to findr?
11. Consider getting rid of color global.
12. `write_flags_table` should never return false.
13. Add a text filter to the multi_select.
9. Audit ssh.odin for places where `#no_bounds_check` would be appropriate.
## Double-check AI output
@@ -54,7 +46,7 @@
- [ ] db_test.odin
- [ ] flags.odin
- [x] main.odin
- [x] prompt.odin
- [ ] prompt.odin
- [x] scan.odin
- [ ] scan_test.odin
- [ ] sodium.odin
+151 -75
View File
@@ -22,8 +22,8 @@ Command :: struct {
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"`,
color: Color_Mode `usage:"Whether or not to colorize output" default:"auto"`,
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"`,
}
@@ -47,6 +47,12 @@ Color_Mode :: enum {
Never,
}
Positional_Arg :: struct {
name: string,
completion: string,
optional: bool,
}
CommandInfo :: struct {
name: string,
usage: string,
@@ -55,59 +61,86 @@ CommandInfo :: struct {
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 {
{
"init",
"envr init",
"Set up envr",
`The init command generates your initial config and saves it to
name = "init",
usage = "envr init",
short = "Set up envr",
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
encrypt your databse. **Make 100% sure** that you have **a remote copy** of this
key somewhere, otherwise your data could be lost forever.`,
{},
GLOBAL_FLAGS + {.Force},
},
{"scan", "envr scan", "Find and select .env files for backup", "", {}, GLOBAL_FLAGS},
{"sync", "envr sync", "Update or restore your env backups", "", {}, GLOBAL_FLAGS + {.Output}},
{"backup", "envr backup <path>", "Import a .env file into envr", "", {"add"}, GLOBAL_FLAGS},
{
"restore",
"envr restore <path>",
"Restore a .env file from the database",
"",
{},
GLOBAL_FLAGS,
},
{"list", "envr list", "View your tracked files", "", {}, GLOBAL_FLAGS + {.Output}},
{
"remove",
"envr remove <path>",
"Remove a .env file from your database",
"",
{},
GLOBAL_FLAGS,
},
{"check", "envr check [path]", "Check if files are backed up", "", {}, GLOBAL_FLAGS},
{"version", "envr version", "Show envr's version", "", {}, {.Help}},
{
"edit-config",
"envr edit-config",
"Edit your config with your default editor",
"",
{},
GLOBAL_FLAGS,
flags = GLOBAL_FLAGS + {.Force},
},
{
"nushell-completion",
"envr nushell-completion",
"Generate custom completions for nushell",
"",
{},
GLOBAL_FLAGS - {.Color},
name = "scan",
usage = "envr scan",
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"}},
},
{
name = "restore",
usage = "envr restore <path>",
short = "Restore a .env file from the database",
flags = GLOBAL_FLAGS,
args = {{name = "path", completion = "tracked-paths"}},
},
{
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"}},
},
{
name = "check",
usage = "envr check [path]",
short = "Check if files are backed up",
flags = GLOBAL_FLAGS,
args = {{name = "path", optional = true}},
},
{
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 = "nushell-completion",
usage = "envr nushell-completion",
short = "Generate custom completions for nushell",
flags = {.Help},
},
}
@@ -206,21 +239,30 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
tbl: table.Table
table.init(&tbl, context.temp_allocator, context.temp_allocator)
table.padding(&tbl, 2, 0)
if write_flags_table(&tbl, info.flags) {
fmt.wprintf(w, "\n", flush = false)
write_borderless_table(w, &tbl)
}
write_flags_table(&tbl, info.flags)
fmt.wprintf(w, "\n", flush = false)
write_borderless_table(w, &tbl)
table_reset(&tbl)
return true
}
flag_field_info :: proc(
ft: Flag_Type,
) -> (
names: string,
value_hint: string,
description: string,
) {
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")
@@ -230,45 +272,81 @@ flag_field_info :: proc(
}
short, has_short := get_subtag(args_tag, "short")
if has_short {
names = fmt.tprintf("-%s, --%s", short, long_name)
} else {
names = fmt.tprintf("--%s", long_name)
}
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 {
value_hint = ""
kind = .Bool
} else if _, is_string := base_ti.variant.(runtime.Type_Info_String); is_string {
value_hint = " <value>"
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))
}
value_hint = fmt.tprintf(" %s", strings.join(parts[:], "|", context.temp_allocator))
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")
description = usage
if len(default_val) > 0 {
if _, is_string := base_ti.variant.(runtime.Type_Info_String); is_string {
description = fmt.tprintf(`%s (default "%s")`, usage, default_val)
} else if _, is_enum := base_ti.variant.(runtime.Type_Info_Enum); is_enum {
description = fmt.tprintf("%s (default '%s')", usage, default_val)
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]) -> (has_rows: bool) {
if flags == {} do return false
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
@@ -285,7 +363,6 @@ write_flags_table :: proc(tbl: ^table.Table, flags: bit_set[Flag_Type]) -> (has_
table.row(tbl, colorize(.Flag, names, tbl.format_allocator), desc)
}
}
return true
}
find_command :: proc(name: string) -> (CommandInfo, bool) {
@@ -365,9 +442,8 @@ at before, restore your backup with:
write_borderless_table(w, &tbl)
table_reset(&tbl)
if write_flags_table(&tbl, GLOBAL_FLAGS) {
write_borderless_table(w, &tbl)
}
write_flags_table(&tbl, GLOBAL_FLAGS)
write_borderless_table(w, &tbl)
table_reset(&tbl)
fmt.wprintf(
+91 -2
View File
@@ -1,10 +1,99 @@
package main
import "core:fmt"
import "core:strings"
COMPLETION_SCRIPT: string : string(#load("mod.nu"))
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']
}
`
cmd_nushell_completion :: proc(cmd: ^Command) {
fmt.wprint(cmd.out, COMPLETION_SCRIPT, flush = false)
fmt.wprint(cmd.out, generate_nushell_completion(), flush = true)
}
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)
}
if len(arg.completion) > 0 {
return fmt.tprintf("%s: path@%s", name, arg.completion)
}
return fmt.tprintf("%s: path", name)
}
+45 -4
View File
@@ -7,31 +7,72 @@ 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")
script := generate_nushell_completion()
testing.expect(t, len(script) > 0, "completion script should not be empty")
}
@(test)
test_nushell_completion_contains_externs :: proc(t: ^testing.T) {
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 help",
"envr init",
"envr list",
"envr remove",
"envr restore",
"envr scan",
"envr sync",
"envr version",
"envr nushell-completion",
}
for ext in expected {
testing.expect(
t,
strings.contains(COMPLETION_SCRIPT, ext),
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)",
)
}
+4 -3
View File
@@ -201,8 +201,7 @@ find_git_roots :: proc(
ok: bool,
) {
paths := search_paths(cfg, allocator)
// TODO: Pass allocator to findr
findr.find_repos(paths[:], &roots, os.get_processor_core_count())
findr.find_repos(paths[:], &roots, os.get_processor_core_count(), allocator)
ok = true
return
}
@@ -214,6 +213,7 @@ search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]st
}
paths := new_clone(cfg.scan_config.include, allocator)
defer free(paths, allocator)
for &include in paths {
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 {
+12
View File
@@ -2,6 +2,7 @@
package main
import "core:fmt"
import "core:mem"
import "core:os"
import "core:path/filepath"
import "core:strings"
@@ -194,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)
}
+1 -1
View File
@@ -5,7 +5,7 @@ import "core:mem"
import "core:os"
MAGIC :: "ENVR"
MAGIC_BYTES := [4]u8{u8('E'), u8('N'), u8('V'), u8('R')}
MAGIC_BYTES: [4]u8 = MAGIC
RECIPIENT_ENTRY_SIZE ::
CRYPTO_BOX_PUBLICKEY_BYTES +
+10 -66
View File
@@ -4,7 +4,6 @@ import "base:runtime"
import "core:crypto/hash"
import "core:encoding/hex"
import "core:encoding/ini"
import "core:encoding/json"
import "core:fmt"
import "core:mem"
import "core:os"
@@ -222,7 +221,6 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
allocator := db_allocator(db)
results := make([dynamic]EnvFile, 0, 10, allocator)
migrate := false
for {
rc = sqlite.step(stmt)
if rc == sqlite.DONE {
@@ -233,24 +231,13 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
#no_bounds_check return results[:], false
}
// TODO: Remove json support after next major release
remotes: [dynamic]string = ---
remotes_raw := string(sqlite.column_text(stmt, 1))
if len(remotes_raw) > 0 {
if remotes_raw[0] == '[' {
err := json.unmarshal_string(remotes_raw, &remotes, allocator = allocator)
if err != nil {
fmt.eprintf("Warning: malformed remotes JSON: %v\n", err)
}
migrate = true
} else {
split := strings.split_lines(remotes_raw, context.temp_allocator)
remotes = make([dynamic]string, 0, len(split), allocator = allocator)
for s in split {
append(&remotes, strings.clone(s, allocator))
}
}
split := strings.split_lines(remotes_raw, context.temp_allocator)
remotes := make([dynamic]string, 0, len(split), allocator = allocator)
for s in split {
append(&remotes, strings.clone(s, allocator))
}
path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
append(
@@ -265,10 +252,6 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
)
}
if migrate {
migrate_remotes(db)
}
#no_bounds_check return results[:], true
}
@@ -364,33 +347,15 @@ db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
return EnvFile{}, false
}
// TODO: Remove json support after next major release
migrate := false
remotes: [dynamic]string = ---
remotes_raw := string(sqlite.column_text(stmt, 1))
if len(remotes_raw) > 0 {
if remotes_raw[0] == '[' {
err := json.unmarshal_string(remotes_raw, &remotes, allocator = allocator)
if err != nil {
fmt.eprintf("Warning: malformed remotes JSON: %v\n", err)
}
migrate = true
} else {
split := strings.split_lines(remotes_raw, context.temp_allocator)
remotes = make([dynamic]string, 0, len(split), allocator = allocator)
for s in split {
append(&remotes, strings.clone(s, allocator))
}
}
split := strings.split_lines(remotes_raw, context.temp_allocator)
remotes := make([dynamic]string, 0, len(split), allocator = allocator)
for s in split {
append(&remotes, strings.clone(s, allocator))
}
file_path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
if migrate {
migrate_remotes(db)
}
return EnvFile {
path = file_path,
dir = filepath.dir(file_path),
@@ -525,29 +490,8 @@ db_persist :: proc(db: ^Db, f: ^EnvFile, old_path: string) -> bool {
return db_insert(db, f^)
}
// TODO: Remove after the next major release
migrate_remotes :: proc(db: ^Db) {
sql ::
"UPDATE envr_env_files " +
"SET remotes = COALESCE((" +
" SELECT group_concat(atom, char(10)) " +
" FROM json_each(envr_env_files.remotes)" +
"), '') " +
"WHERE remotes LIKE '[%'"
rc := sqlite.exec(db.conn, sql, nil, nil, nil)
if rc != sqlite.OK {
fmt.eprintf("Warning: failed to migrate remotes: %s\n", sqlite.errmsg(db.conn))
return
}
if sqlite.changes(db.conn) > 0 {
db.changed = true
}
}
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 {
return false, .GitRootFailed
}
+9 -1
View File
@@ -16,7 +16,14 @@ RepoPool :: struct {
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
pool := new(RepoPool)
@@ -126,3 +133,4 @@ process_repo_dir :: proc(pool: ^RepoPool, dir_path: string) {
}
}
}
+9 -1
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
ch, _ := chan.create(chan.Chan([]u8), max(2 * thread_count, 2), context.allocator)
+1 -1
View File
@@ -66,7 +66,7 @@
packages.default = pkgs.stdenv.mkDerivation rec {
pname = "envr";
version = "0.3.0";
version = nixpkgs.lib.trim (builtins.readFile ./version.txt);
src = ./.;
nativeBuildInputs = [
-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
]
+76 -26
View File
@@ -1,6 +1,7 @@
package main
import "core:fmt"
import "core:strings"
import "core:sys/posix"
import "core:terminal/ansi"
@@ -15,6 +16,8 @@ Key :: enum {
Space,
Enter,
Escape,
Backspace,
Char,
Unknown,
}
@@ -38,11 +41,17 @@ multi_select :: proc(
}
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
scroll_offset: int = 0
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)
if !ok {
@@ -52,19 +61,31 @@ multi_select :: proc(
defer disable_raw_mode(&raw)
for {
key := read_key()
key, ch := read_key()
switch key {
case .Char:
append(&filter, u8(ch))
rebuild_filtered(options, filter[:], &filtered)
cursor = clamp(cursor, 0, max(0, len(filtered) - 1))
case .Backspace:
if len(filter) > 0 {
pop(&filter)
rebuild_filtered(options, filter[:], &filtered)
cursor = clamp(cursor, 0, max(0, len(filtered) - 1))
}
case .Up:
if cursor > 0 {
cursor -= 1
}
case .Down:
if cursor < len(options) - 1 {
if cursor < len(filtered) - 1 {
cursor += 1
}
case .Space:
selected[cursor] = !selected[cursor]
if len(filtered) > 0 {
selected[filtered[cursor]] = !selected[filtered[cursor]]
}
case .Enter:
fmt.printf(
ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW,
@@ -82,9 +103,21 @@ multi_select :: proc(
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)
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)
}
}
}
@@ -92,22 +125,34 @@ render_options :: proc(
prompt: string,
options: []string,
selected: []bool,
filtered: []int,
filter_text: string,
cursor: int,
scroll_offset: int,
) -> int {
fmt.printf(
"%s (↑/↓ move, space select, enter confirm)\r\n",
"%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
if end > len(options) {
end = len(options)
if end > len(filtered) {
end = len(filtered)
}
for i in scroll_offset ..< end {
original := filtered[i]
checkbox := " "
if selected[i] {
if selected[original] {
checkbox = "x"
}
if i == cursor {
@@ -115,14 +160,14 @@ render_options :: proc(
"%s [%s] %s\r\n",
colorize(.Caret, ">"),
colorize(.Sucess, checkbox),
options[i],
options[original],
)
} else {
fmt.printf(" [%s] %s\r\n", colorize(.Sucess, 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) {
@@ -152,21 +197,23 @@ disable_raw_mode :: proc(state: ^Raw_State) {
posix.tcsetattr(state.fd, .TCSAFLUSH, &state.original)
}
read_key :: proc() -> Key {
read_key :: proc() -> (key: Key, ch: rune) {
buf: [3]u8
n := posix.read(posix.STDIN_FILENO, &buf[0], 1)
if n <= 0 {
return .Unknown
return .Unknown, 0
}
switch buf[0] {
case ' ':
return .Space
return .Space, 0
case '\n', '\r':
return .Enter
return .Enter, 0
case 0x03:
return .Escape
return .Escape, 0
case 0x08, 0x7F:
return .Backspace, 0
case 0x1b:
tv: posix.timeval
tv.tv_sec = 0
@@ -178,12 +225,12 @@ read_key :: proc() -> Key {
ready := posix.select(1, &set, nil, nil, &tv)
if ready <= 0 {
return .Escape
return .Escape, 0
}
n2 := posix.read(posix.STDIN_FILENO, &buf[1], 1)
if n2 <= 0 || buf[1] != '[' {
return .Escape
return .Escape, 0
}
posix.FD_ZERO(&set)
@@ -193,24 +240,27 @@ read_key :: proc() -> Key {
ready = posix.select(1, &set, nil, nil, &tv)
if ready <= 0 {
return .Escape
return .Escape, 0
}
n3 := posix.read(posix.STDIN_FILENO, &buf[2], 1)
if n3 <= 0 {
return .Escape
return .Escape, 0
}
switch buf[2] {
case 'A':
return .Up
return .Up, 0
case 'B':
return .Down
return .Down, 0
case:
return .Escape
return .Escape, 0
}
case:
return .Unknown
if buf[0] >= 0x20 && buf[0] <= 0x7E {
return .Char, rune(buf[0])
}
return .Unknown, 0
}
}
+40 -55
View File
@@ -35,18 +35,18 @@ parse_ssh_public_key :: proc(pub_path: string) -> (pub: [32]u8, ok: bool) {
return
}
offset := 0
key_type, type_ok := read_wire_string(decoded, &offset)
if !type_ok || key_type != SSH_ED25519 {
rest := decoded
key_type, type_ok := read_wire_string(&rest)
if !type_ok || string(key_type) != SSH_ED25519 {
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 {
return
}
mem.copy_non_overlapping(&pub[0], raw_data(pk_data), 32)
mem.copy_non_overlapping(&pub[0], &pk_data[0], 32)
ok = true
return
@@ -91,81 +91,63 @@ parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: boo
return
}
offset := len(magic)
rest := decoded[len(magic):]
ciphername, cipher_ok := read_wire_string(decoded, &offset)
if !cipher_ok || ciphername != "none" {
ciphername, cipher_ok := read_wire_string(&rest)
if !cipher_ok || string(ciphername) != "none" {
return
}
kdfname, kdf_ok := read_wire_string(decoded, &offset)
if !kdf_ok || kdfname != "none" {
kdfname, kdf_ok := read_wire_string(&rest)
if !kdf_ok || string(kdfname) != "none" {
return
}
_, opts_ok := read_wire_string(decoded, &offset)
_, opts_ok := read_wire_string(&rest)
if !opts_ok {
return
}
if offset + 4 > len(decoded) {
num_keys, nkeys_ok := read_wire_u32(&rest)
if !nkeys_ok || num_keys != 1 {
return
}
num_keys := endian.get_u32(decoded[offset:offset + 4], .Big) or_return
offset += 4
if num_keys != 1 {
return
}
_, pub_blob_ok := read_wire_string(decoded, &offset)
_, pub_blob_ok := read_wire_string(&rest)
if !pub_blob_ok {
return
}
priv_blob, priv_blob_ok := read_wire_string(decoded, &offset)
priv_blob, priv_blob_ok := read_wire_string(&rest)
if !priv_blob_ok {
return
}
inner_offset := 0
if inner_offset + 8 > len(priv_blob) {
inner := priv_blob
check1, c1_ok := read_wire_u32(&inner)
check2, c2_ok := read_wire_u32(&inner)
if !c1_ok || !c2_ok || check1 != check2 {
return
}
check1 := endian.get_u32(
transmute([]u8)(priv_blob)[inner_offset:inner_offset + 4],
.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 {
priv_type, type_ok := read_wire_string(&inner)
if !type_ok || string(priv_type) != SSH_ED25519 {
return
}
priv_type, type_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
if !type_ok || priv_type != SSH_ED25519 {
return
}
pub_wire, pub_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
pub_wire, pub_ok := read_wire_string(&inner)
if !pub_ok || len(pub_wire) != 32 {
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 {
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
return
@@ -182,19 +164,22 @@ is_ed25519_key :: proc(
return ok, nil
}
read_wire_string :: proc(data: []u8, offset: ^int) -> (s: string, ok: bool) {
if offset^ + 4 > len(data) {
return
}
length := endian.get_u32(data[offset^:offset^ + 4], .Big) or_return
offset^ += 4
read_wire_string :: proc(data: ^[]u8) -> (s: []u8, ok: bool) {
if len(data^) < 4 do return
length := endian.get_u32(data^[:4], .Big) or_return
data^ = data^[4:]
if offset^ + int(length) > len(data) {
return
}
if len(data^) < int(length) do return
s = data^[:int(length)]
data^ = data^[int(length):]
ok = true
return
}
s = string(data[offset^:offset^ + int(length)])
offset^ += int(length)
read_wire_u32 :: proc(data: ^[]u8) -> (v: u32, ok: bool) {
if len(data^) < 4 do return
v = endian.get_u32(data^[:4], .Big) or_return
data^ = data^[4:]
ok = true
return
}
+6 -6
View File
@@ -52,15 +52,15 @@ test_private_key_pub_matches_public_key :: proc(t: ^testing.T) {
@(test)
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}
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_value(t, s, "hello")
testing.expect_value(t, offset, 9)
testing.expect_value(t, string(s), "hello")
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_value(t, s2, "")
testing.expect_value(t, len(s2), 0)
}
+1
View File
@@ -35,6 +35,7 @@ test_ansi_aware_width_multiple_escape_sequences :: proc(t: ^testing.T) {
colorize(.Heading, "b", disable = false),
colorize(.Heading, "c", disable = false),
},
context.temp_allocator,
)
testing.expect_value(t, ansi_aware_width(colored), 3)
}