Author SHA1 Message Date
spencer e74fc4f35a feat: Added --format, -f flag.
Allows printing data in tabular or json format.
2026-06-25 17:51:31 -04:00
64 changed files with 805 additions and 3098 deletions
-2
View File
@@ -1,3 +1 @@
use flake use flake
export PATH="./:$PATH"
-1
View File
@@ -2,7 +2,6 @@ on:
push: push:
branches: branches:
- main - main
- prerelease
permissions: permissions:
contents: write contents: write
-1
View File
@@ -15,6 +15,5 @@ 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
-72
View File
@@ -1,77 +1,5 @@
# Changelog # Changelog
## [1.1.0](https://github.com/sbrow/envr/compare/v1.0.0...v1.1.0) (2026-10-15)
### Features
* Added `view` command for opening files. ([d4c4ed8](https://github.com/sbrow/envr/commit/d4c4ed8468ab975db1f22ff862fcb2eea475ae6e))
* Added support for remote files (via ssh). ([e0e9c93](https://github.com/sbrow/envr/commit/e0e9c93577250fe8d486cd0909bbe6909fb97c75))
## [1.0.0](https://github.com/sbrow/envr/compare/v0.4.0...v1.0.0) (2026-07-27)
### ⚠ 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 := $(file < version.txt) VERSION := $(shell grep 'version = ' flake.nix | head -1 | sed 's/.*version = "\(.*\)";/\1/')
BUILD_DIR := builds BUILD_DIR := builds
# Binary names # Binary names
-18
View File
@@ -10,8 +10,6 @@ in an encyrpted sqlite database. Changes can be effortlessly synced with
`envr` puts all your .env files in one safe place, so you can back them up with `envr` puts all your .env files in one safe place, so you can back them up with
the tool [of your choosing](#backup-options). the tool [of your choosing](#backup-options).
Please read the [AI Disclosure](#ai-disclosure) if you're concerned about AI code.
## Features ## Features
- **Encrypted Storage**: All `.env` files are encrypted using your ssh key and - **Encrypted Storage**: All `.env` files are encrypted using your ssh key and
@@ -127,22 +125,6 @@ location.
[restic](https://restic.readthedocs.io/en/latest/010_introduction.html). [restic](https://restic.readthedocs.io/en/latest/010_introduction.html).
## AI Disclosure
This project was largely built by AI. The original project (written in go) was
written almost entirely with AI, using libraries that I requested (i.e. cobra).
I wrote it at work because I needed the tool and wanted it fast. The Odin version
was also primarily written by AI, but I stepped in a lot more, and took care to
architect the project in the way that I wanted, and made sure tests were written
to catch leaks.
Most of the documentation was written by hand, but I think the original `README.md`
was at least fleshed out by AI.
It is by no means perfect, but I have spent a lot of time doing code review and
testing to make sure the program (and documentation) meet my personal standards.
## License ## License
This project is licensed under the [MIT License](./LICENSE). This project is licensed under the [MIT License](./LICENSE).
+21 -12
View File
@@ -1,24 +1,34 @@
# TODOs # TODOs
1. Bring back windows support / cross-compilation. 1. Commands are still leaking. (Write tests for everything first)
2. Commands are still leaking. (Write tests for everything first) 2. Add color flag and support non colored output.
3. procedures should be ordered by use, main at the top, then in the order they are called from main. 3. Rewrite `write_command_help` to use text/tables
4. Test all command branches. 4. Generate md and man pages again.
6. Add tests for untested commands. 5. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
7. Add purge command? 6. Add a text filter to the multi_select.
8. Add docs for "building from source" 7. Add tests for untested commands.
9. Add `envr show` command that uses pager (`less`) unless -no-pager is passed. 8. add --format -f flag to commands that draw tables.
10. Run remote SSH sync operations in parallel. 9. procedures should be ordered by use, main at the top, then in the order they are called from main.
11. Envr sync should require confirmation before pushing remote files. 10. Shell completion
11. Bring back windows support / cross-compilation.
12. Test all cmds / terminal branches.
13. Pass allocator to findr?
14. Update `read_wire_string` to use a slice.
15. `-h` short flag seems to fail, at least with `envr list`
## Double-check AI output ## Double-check AI output
@@ -46,9 +56,8 @@
- [ ] 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
- [ ] prompt.odin - [x] prompt.odin
- [x] scan.odin - [x] scan.odin
- [ ] scan_test.odin - [ ] scan_test.odin
- [ ] sodium.odin - [ ] sodium.odin
+152 -317
View File
@@ -1,11 +1,9 @@
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:terminal"
import "core:text/table" import "core:text/table"
@@ -13,180 +11,53 @@ import "core:text/table"
Command :: struct { Command :: struct {
name: string, name: string,
args: [dynamic]string, args: [dynamic]string,
flags: Flags, flags: map[string]string,
bool_set: map[string]bool,
config_path: string,
out_buf: ^bufio.Writer, out_buf: ^bufio.Writer,
out: io.Writer, out: io.Writer,
err: 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"`,
stdout: bool `args:"short=p" usage:"Print file contents directly to stdout instead of using a pager"`,
}
Flag_Type :: enum {
Help,
Config_File,
Output,
Color,
Force,
Stdout,
}
Output_Format :: enum { Output_Format :: enum {
Auto,
Table, Table,
JSON, 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 {
name: string, name: string,
usage: string, usage: string,
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 {
{ {
name = "init", "init",
usage = "envr init", "envr init",
short = "Set up envr", "Set up envr",
long = `The init command generates your initial config and saves it to `The init command generates your initial config and saves it to
~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to ~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to
encrypt your databse. **Make 100% sure** that you have **a remote copy** of this encrypt your databse. **Make 100% sure** that you have **a remote copy** of this
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", "", {}},
{ {
name = "scan", "nushell-completion",
usage = "envr scan", "envr nushell-completion",
short = "Find and select .env files for backup", "Generate custom completions for nushell",
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 + {.Force},
args = {
{name = "path", completion = "tracked-paths", desc = "Path to .env file to restore"},
},
},
{
name = "list",
usage = "envr list",
short = "View your tracked files",
long = "Table output is sorted by stored path and numbered from 1. Use 'envr view <number>' to view the contents of a backup.",
flags = GLOBAL_FLAGS + {.Output},
},
{
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 = "view",
usage = "envr view [path]",
short = "View a backed-up .env file",
long = "If no path is provided, the user will be asked to select one from a list. You may also pass the number from the \"#\" column of `envr list` instead of the full path. If a partial path is passed, the closest match will be opened.",
flags = GLOBAL_FLAGS + {.Stdout},
args = {
{
name = "path",
completion = "tracked-paths",
optional = true,
desc = "Path or substring, or number of a backed-up file.",
},
},
},
{name = "version", usage = "envr version", short = "Show envr's version", flags = {.Help}},
{
name = "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},
}, },
} }
@@ -206,33 +77,53 @@ 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:]))
overflow := parse_flags(&cmd.flags, args[2:]) cmd.args = make([dynamic]string)
for arg in overflow { cmd.flags = make(map[string]string)
cmd.bool_set = make(map[string]bool)
// 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) append(&cmd.args, arg)
i += 1
}
} }
if cmd.flags.output == .Auto { val: string = ---
cmd.flags.output = terminal.is_terminal(os.stdout) ? .Table : .JSON if val, ok = cmd.flags["config-file"]; ok {
} cmd.config_path = val
} else if val, ok = cmd.flags["c"]; ok {
if cmd.flags.color == .Auto { cmd.config_path = val
cmd.flags.color = terminal.is_terminal(os.stdout) ? .Always : .Never } else {
}
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.flags.config_file = default_config_path(home, context.temp_allocator) cmd.config_path = default_config_path(home, context.temp_allocator)
} }
if cmd.flags.help { if has_flag(&cmd, "help") {
print_command_help(&cmd) print_command_help(&cmd)
return cmd, false return cmd, false
} }
@@ -249,7 +140,6 @@ 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
@@ -257,23 +147,36 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
fmt.wprintf( fmt.wprintf(
w, w,
"%s\n\n\n%s\n\n %s [flags]\n\n", "%s\n\n\n" +
COLOR_HEADINGS +
"Usage:" +
ANSI_RESET +
"\n\n " +
COLOR_FLAGS +
"%s" +
ANSI_RESET +
" [flags]\n\n",
info.short, info.short,
colorize(.Heading, "Usage:"), info.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%s\n\n %s", "\n" +
colorize(.Heading, "Aliases:"), COLOR_HEADINGS +
colorize(.Command, info.name), "Aliases:" +
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, ", %s", colorize(.Command, a), flush = false) fmt.wprintf(w, ", " + COLOR_COMMANDS + "%s" + ANSI_RESET, a, flush = false)
} }
fmt.wprintf(w, "\n", flush = false) fmt.wprintf(w, "\n", flush = false)
} }
@@ -282,133 +185,26 @@ 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)
} }
tbl: table.Table fmt.wprintf(
table.init(&tbl, context.temp_allocator, context.temp_allocator) w,
table.padding(&tbl, 2, 0) "\n" +
write_flags_table(&tbl, info.flags) COLOR_HEADINGS +
fmt.wprintf(w, "\n", flush = false) "Flags:" +
write_borderless_table(w, &tbl) ANSI_RESET +
table_reset(&tbl) "\n\n " +
return true COLOR_FLAGS +
} "-h, --help" +
ANSI_RESET +
Flag_Kind :: enum { " help for %s\n " +
Bool, COLOR_FLAGS +
String, "-c, --config-file" +
Enum, ANSI_RESET +
} ` <path> config file (default "~/.envr/config.json")
`,
Flag_Field :: struct { info.name,
long_name: string, flush = false,
short_name: string,
kind: Flag_Kind,
usage: string,
default_val: string,
enum_values: string,
completion: string,
}
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) return true
} else {
table.row(tbl, colorize(.Flag, names, tbl.format_allocator), desc)
}
}
}
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
}
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,
}
} }
find_command :: proc(name: string) -> (CommandInfo, bool) { find_command :: proc(name: string) -> (CommandInfo, bool) {
@@ -425,7 +221,11 @@ find_command :: proc(name: string) -> (CommandInfo, bool) {
return CommandInfo{}, false return CommandInfo{}, false
} }
ENVR_DESCRIPTION :: `envr keeps your .env synced to a local, encrypted database. // TODO: command args should be shown in usage.
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.
@@ -454,16 +254,17 @@ 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
// TODO: command args should be shown in usage. %sUsage:%s
write_usage :: proc(w: io.Writer) {
fmt.wprintf( %senvr%s [command]
w,
"%s\n\n%s\n\n %s [command]\n\n", `,
ENVR_DESCRIPTION, COLOR_HEADINGS,
colorize(.Heading, "Usage:"), ANSI_RESET,
colorize(.Flag, "envr"), COLOR_FLAGS,
ANSI_RESET,
flush = false, flush = false,
) )
@@ -479,27 +280,61 @@ write_usage :: proc(w: io.Writer) {
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, colorize(.Command, name, tbl.format_allocator), c.short) table.row(&tbl, table.format(&tbl, "%s%s%s", COLOR_COMMANDS, name, ANSI_RESET), c.short)
} }
write_borderless_table(w, &tbl) write_borderless_table(w, &tbl)
table_reset(&tbl) table_reset(&tbl)
write_flags_table(&tbl, GLOBAL_FLAGS) table.caption(&tbl, "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")`,
)
table.row(
&tbl,
COLOR_FLAGS + "-f, --format" + ANSI_RESET + " 'json'|'table'",
`the format of output data. (default 'table', unless piping)`,
)
write_borderless_table(w, &tbl) write_borderless_table(w, &tbl)
table_reset(&tbl)
fmt.wprintf( fmt.wprintf(
w, w,
`Use "%s [command] --help" for more information about a command.`, `Use "%senvr%s [command] --help" for more information about a command.`,
colorize(.Flag, "envr", tbl.format_allocator), COLOR_FLAGS,
ANSI_RESET,
flush = false, flush = false,
) )
} }
has_flag :: proc(cmd: ^Command, name: string) -> bool {
return name in cmd.flags || name in cmd.bool_set
}
get_format :: proc(cmd: ^Command) -> Output_Format {
flags :: []string{"format", "f"}
for name in flags {
if val, ok := cmd.flags[name]; ok {
switch val {
case "json":
return .JSON
case "table":
return .Table
}
}
}
return terminal.is_terminal(os.stdout) ? .Table : .JSON
}
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)
} }
+102 -43
View File
@@ -110,8 +110,7 @@ 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, "help for init"), "missing 'help for init'") testing.expect(t, strings.contains(text, "help for init"), "missing 'help for init'")
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) {
@@ -145,6 +144,53 @@ test_command_help_version :: proc(t: ^testing.T) {
} }
@(test) @(test)
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,
@@ -180,6 +226,8 @@ 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.flags), 0) testing.expect_value(t, len(cmd.flags), 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) {
@@ -194,45 +242,43 @@ test_parse_args_positional :: proc(t: ^testing.T) {
@(test) @(test)
test_parse_args_long_flag_with_value :: proc(t: ^testing.T) { test_parse_args_long_flag_with_value :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args( cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "--config", "x.json"})
[]string{"envr", "sync", "--config-file", "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"], "x.json") testing.expect_value(t, cmd.flags["config"], "x.json")
} }
@(test) @(test)
test_parse_args_short_flag_with_value :: proc(t: ^testing.T) { test_parse_args_short_flag_with_value :: 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["c"], "x.json") testing.expect_value(t, cmd.flags["c"], "x.json")
} }
@(test) @(test)
test_parse_args_long_bool_flag :: proc(t: ^testing.T) { test_parse_args_long_bool_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.bool_set["force"], true) testing.expect_value(t, cmd.bool_set["force"], true)
} }
@(test) @(test)
test_parse_args_short_bool_flag :: proc(t: ^testing.T) { test_parse_args_short_bool_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "-f"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "version", "-l"})
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.bool_set["l"], true) testing.expect_value(t, cmd.bool_set["l"], true)
} }
@(test) @(test)
test_parse_args_multiple_positionals :: proc(t: ^testing.T) { test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
@@ -254,7 +300,7 @@ test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
defer delete_command(&cmd) defer delete_command(&cmd)
testing.expect_value(t, cmd.bool_set["force"], true) testing.expect_value(t, cmd.bool_set["force"], true)
testing.expect_value(t, len(cmd.args), 1) testing.expect_value(t, len(cmd.args), 1)
testing.expect_value(t, cmd.args[0], "/project/.env") testing.expect_value(t, cmd.args[0], "/project/.env")
} }
@@ -268,77 +314,90 @@ 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", "a.env", "--force", "--verbose"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "a.env", "--force", "--verbose"})
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.bool_set["force"], true) testing.expect_value(t, cmd.bool_set["force"], true)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON) testing.expect_value(t, cmd.bool_set["verbose"], true)
testing.expect_value(t, len(cmd.args), 1) testing.expect_value(t, len(cmd.args), 1)
testing.expect_value(t, cmd.args[0], "a.env") testing.expect_value(t, cmd.args[0], "a.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", "list"}) cmd, ok, _, _ := test_parse_args(
[]string{"envr", "list", "--config-file", "/custom/config.json"},
)
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.config_path, "/custom/config.json")
}
@(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_value(t, cmd.config_path, "/custom/config.json")
}
@(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.config_path) > 0, "config_path should default to non-empty path") testing.expect(t, len(cmd.config_path) > 0, "config_path should default to non-empty path")
testing.expect( testing.expect(
t, t,
strings.contains(cmd.config_path, ".envr"), strings.contains(cmd.config_path, ".envr"),
"default config_file should contain .envr dir, got %s", "default config_path should contain .envr dir, got %s",
) )
} }
@(test) @(test)
test_get_format_long_json :: proc(t: ^testing.T) { test_get_format_long_json :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--output", "json"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--format", "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, get_format(&cmd), Output_Format.JSON) testing.expect_value(t, get_format(&cmd), Output_Format.JSON)
} }
@(test) @(test)
test_get_format_short_json :: proc(t: ^testing.T) { test_get_format_short_json :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-o", "json"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-f", "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, get_format(&cmd), Output_Format.JSON) testing.expect_value(t, get_format(&cmd), Output_Format.JSON)
} }
@(test) @(test)
test_get_format_long_table :: proc(t: ^testing.T) { test_get_format_long_table :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--output", "table"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--format", "table"})
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, get_format(&cmd), Output_Format.Table) testing.expect_value(t, get_format(&cmd), Output_Format.Table)
} }
@(test) @(test)
test_get_format_short_table :: proc(t: ^testing.T) { test_get_format_short_table :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-o", "table"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-f", "table"})
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, get_format(&cmd), Output_Format.Table) testing.expect_value(t, get_format(&cmd), Output_Format.Table)
} }
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.flags.config_file) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
+9 -24
View File
@@ -7,53 +7,43 @@ import "core:path/filepath"
// TODO: What happens if you pass a non existent path to cmd_check? // TODO: What happens if you pass a non existent path to cmd_check?
// TODO: UX could be improved, so "run envr add ." if file not exists. // TODO: UX could be improved, so "run envr add ." if file not exists.
cmd_check :: proc(cmd: ^Command) { cmd_check :: proc(cmd: ^Command) {
input_path: string _check_path: string
if len(cmd.args) > 0 { if len(cmd.args) > 0 {
input_path = cmd.args[0] _check_path = cmd.args[0]
} else { } else {
cwd, cwd_err := os.get_working_directory(context.temp_allocator) cwd, cwd_err := os.get_working_directory(context.temp_allocator)
if cwd_err != nil { if cwd_err != nil {
fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false) fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false)
return return
} }
input_path = cwd _check_path = cwd
} }
resolved_path, _, _, is_remote, path_resolved := normalize_remote_identity(input_path) check_path, abs_err := filepath.abs(_check_path, context.temp_allocator)
if is_remote && !path_resolved {
return
}
if !is_remote {
abs_path, abs_err := filepath.abs(input_path, context.temp_allocator)
if abs_err != nil { if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
resolved_path = abs_path
}
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
defer db_close(&db) defer db_close(&db)
is_dir := false is_dir := os.is_directory(check_path)
if !is_remote {
is_dir = os.is_directory(resolved_path)
}
// TODO: set a reasonable default // TODO: set a reasonable default
files_in_path := make([dynamic]string, context.temp_allocator) files_in_path := make([dynamic]string, context.temp_allocator)
if is_dir { if is_dir {
scanned, scan_ok := scan_path(resolved_path, db.cfg) scanned, scan_ok := scan_path(check_path, db.cfg)
if !scan_ok { if !scan_ok {
fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false) fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false)
return return
} }
files_in_path = scanned files_in_path = scanned
} else { } else {
append(&files_in_path, resolved_path) append(&files_in_path, check_path)
} }
db_files, list_ok := db_list(&db) db_files, list_ok := db_list(&db)
@@ -66,8 +56,6 @@ cmd_check :: proc(cmd: ^Command) {
if len(not_backed) == 0 { if len(not_backed) == 0 {
if len(files_in_path) == 0 { if len(files_in_path) == 0 {
fmt.wprintln(cmd.out, "No .env files found in the specified directory.", flush = false) fmt.wprintln(cmd.out, "No .env files found in the specified directory.", flush = false)
} else if is_remote {
fmt.wprintln(cmd.out, "✓ Remote file is backed up.", flush = false)
} else { } else {
fmt.wprintln( fmt.wprintln(
cmd.out, cmd.out,
@@ -85,10 +73,7 @@ cmd_check :: proc(cmd: ^Command) {
for file in not_backed { for file in not_backed {
fmt.wprintf(cmd.out, " %s\n", file, flush = false) fmt.wprintf(cmd.out, " %s\n", file, flush = false)
} }
if is_remote {
fmt.wprintf(cmd.out, "\nRun 'envr backup %s' to back up this remote file.\n", resolved_path, flush = false)
} else {
fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false) fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false)
} }
} }
}
-43
View File
@@ -2,10 +2,6 @@
package main package main
import "core:testing" import "core:testing"
import "core:bufio"
import "core:os"
import "core:path/filepath"
import "core:strings"
@(test) @(test)
test_find_unbacked_finds_missing :: proc(t: ^testing.T) { test_find_unbacked_finds_missing :: proc(t: ^testing.T) {
@@ -46,42 +42,3 @@ test_find_unbacked_none_backed :: proc(t: ^testing.T) {
testing.expect_value(t, len(result), 2) testing.expect_value(t, len(result), 2)
} }
@(test)
test_cmd_check_remote_path :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-check-remote-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join({base, "config.json"}, context.temp_allocator)
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "config should save")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
file := make_test_env_file("spencer@example.com:/etc/umami.env", "abc123", "SECRET=value")
testing.expect(t, db_insert(&db, file), "remote record should insert")
db_close(&db)
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "check", "spencer@example.com:/etc/umami.env", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "command should parse")
if !ok do return
defer delete_command(&cmd)
cmd_check(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
error_output := strings.to_string(err_b)
testing.expect(t, strings.contains(output, "Remote file is backed up"), "tracked remote should report backed up")
testing.expect(t, len(error_output) == 0, "remote path should not be treated as a local path")
}
-268
View File
@@ -1,268 +0,0 @@
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
@@ -1,133 +0,0 @@
#+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.flags.config_file config_path := cmd.config_path
if !os.exists(config_path) { if !os.exists(config_path) {
fmt.wprintf( fmt.wprintf(
+8 -13
View File
@@ -1,14 +1,14 @@
package main package main
import "core:fmt" import "core:fmt"
import "core:terminal/ansi"
cmd_init :: proc(cmd: ^Command) { cmd_init :: proc(cmd: ^Command) {
force := cmd.flags.force force := has_flag(cmd, "force") || has_flag(cmd, "f")
config_file := cmd.flags.config_file
fmt.wprintln(cmd.out, cmd.flags.config_file, flush = false) fmt.wprintln(cmd.out, cmd.config_path, flush = false)
_, cfg_exists := load_config(config_file) _, cfg_exists := load_config(cmd.config_path)
if cfg_exists && !force { if cfg_exists && !force {
fmt.wprintln( fmt.wprintln(
cmd.out, cmd.out,
@@ -25,19 +25,15 @@ Run again with the --force flag if you want to reinitialize.`,
} }
if len(keys) == 0 { if len(keys) == 0 {
fmt.wprintln( fmt.wprintln(cmd.err, `No ssh-ed25519 keys found in ~/.ssh
cmd.err, Generate one with: ssh-keygen -t ed25519`, flush = false)
`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, colorize(.Message, "Cancelled."), flush = false) fmt.wprintln(cmd.out, ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET, flush = false)
return return
} }
@@ -53,7 +49,7 @@ Generate one with: ssh-keygen -t ed25519`,
return return
} }
cfg := new_config(selected_paths[:], config_file) cfg := new_config(selected_paths[:], cmd.config_path)
if !save_config(cfg, force = force) { if !save_config(cfg, force = force) {
return return
} }
@@ -65,4 +61,3 @@ Generate one with: ssh-keygen -t ed25519`,
flush = false, flush = false,
) )
} }
+7 -7
View File
@@ -14,7 +14,7 @@ ListEntry :: struct {
// TODO: Improve table rendering // TODO: Improve table rendering
cmd_list :: proc(cmd: ^Command) { cmd_list :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
@@ -25,26 +25,25 @@ cmd_list :: proc(cmd: ^Command) {
return return
} }
if cmd.flags.output == .Table { if get_format(cmd) == .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,
colorize(.Table_Heading, "#"), COLOR_TABLE_HEADING + "Directory" + ANSI_RESET,
colorize(.Table_Heading, "Directory"), COLOR_TABLE_HEADING + "Path" + ANSI_RESET,
colorize(.Table_Heading, "Path"),
) )
for row, i in rows { for row in rows {
dir_str := strings.concatenate( dir_str := strings.concatenate(
{row.dir, os.Path_Separator_String}, {row.dir, os.Path_Separator_String},
context.temp_allocator, context.temp_allocator,
) )
filename := filepath.base(row.path) filename := filepath.base(row.path)
table.row(&t, fmt.tprintf("%d", i + 1), dir_str, filename) table.row(&t, dir_str, filename)
} }
table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width) table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width)
@@ -74,3 +73,4 @@ cmd_list :: proc(cmd: ^Command) {
fmt.wprintln(cmd.out, string(data), flush = false) fmt.wprintln(cmd.out, string(data), flush = false)
} }
} }
+5 -22
View File
@@ -22,7 +22,7 @@ test_filepath_base_equals_rel :: proc(t: ^testing.T) {
} }
@(test) @(test)
test_cmd_list_output_json :: proc(t: ^testing.T) { test_cmd_list_format_json :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-list-json-*") base := test_temp_dir(t, "envr-test-list-json-*")
defer os.remove_all(base) defer os.remove_all(base)
@@ -47,7 +47,7 @@ test_cmd_list_output_json :: proc(t: ^testing.T) {
defer strings.builder_destroy(&err_b) defer strings.builder_destroy(&err_b)
cmd, ok := parse_args( cmd, ok := parse_args(
[]string{"envr", "list", "--output", "json", "--config-file", cfg_path}, []string{"envr", "list", "--format", "json", "--config-file", cfg_path},
strings.to_stream(&out_b), strings.to_stream(&out_b),
strings.to_stream(&err_b), strings.to_stream(&err_b),
) )
@@ -68,7 +68,7 @@ test_cmd_list_output_json :: proc(t: ^testing.T) {
} }
@(test) @(test)
test_cmd_list_output_table :: proc(t: ^testing.T) { test_cmd_list_format_table :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-list-table-*") base := test_temp_dir(t, "envr-test-list-table-*")
defer os.remove_all(base) defer os.remove_all(base)
@@ -80,12 +80,9 @@ test_cmd_list_output_table :: proc(t: ^testing.T) {
db, db_ok := db_open(cfg_path) db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open") testing.expect(t, db_ok, "db should open")
if !db_ok do return if !db_ok do return
paths := []string{"/zeta/.env", "/alpha/.env", "/middle/.env"} f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
for path in paths {
f := make_test_env_file(path, "abc123", "SECRET=value")
defer delete(f.remotes) defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed") testing.expect(t, db_insert(&db, f), "insert should succeed")
}
db_close(&db) db_close(&db)
out_b: strings.Builder out_b: strings.Builder
@@ -96,7 +93,7 @@ test_cmd_list_output_table :: proc(t: ^testing.T) {
defer strings.builder_destroy(&err_b) defer strings.builder_destroy(&err_b)
cmd, ok := parse_args( cmd, ok := parse_args(
[]string{"envr", "list", "--output", "table", "--config-file", cfg_path}, []string{"envr", "list", "--format", "table", "--config-file", cfg_path},
strings.to_stream(&out_b), strings.to_stream(&out_b),
strings.to_stream(&err_b), strings.to_stream(&err_b),
) )
@@ -109,20 +106,6 @@ test_cmd_list_output_table :: proc(t: ^testing.T) {
output := strings.to_string(out_b) output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "│"), "table output should contain border chars") testing.expect(t, strings.contains(output, "│"), "table output should contain border chars")
testing.expect(t, strings.contains(output, "#"), "table output should show a number header")
alpha := strings.index(output, "/alpha/")
middle := strings.index(output, "/middle/")
zeta := strings.index(output, "/zeta/")
testing.expect(
t,
alpha >= 0 && alpha < middle && middle < zeta,
"table should be sorted by full stored path",
)
testing.expect(
t,
strings.contains(output, "│ 1 │ /alpha/"),
"first path should be numbered 1",
)
testing.expect( testing.expect(
t, t,
strings.contains(output, "Directory"), strings.contains(output, "Directory"),
+10
View File
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,37 @@
#+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),
)
}
}
+4 -10
View File
@@ -16,28 +16,22 @@ cmd_remove :: proc(cmd: ^Command) {
return return
} }
resolved_path, _, _, is_remote, path_resolved := normalize_remote_identity(path)
if is_remote && !path_resolved {
return
}
if !is_remote {
abs_path, abs_err := filepath.abs(path, context.temp_allocator) abs_path, abs_err := filepath.abs(path, context.temp_allocator)
if abs_err != nil { if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
resolved_path = abs_path
}
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
defer db_close(&db) defer db_close(&db)
if !db_delete(&db, resolved_path) { if !db_delete(&db, abs_path) {
return return
} }
fmt.wprintf(cmd.out, "Removed %s from the database\n", resolved_path, flush = false) fmt.wprintf(cmd.out, "Removed %s from the database\n", abs_path, flush = false)
} }
+3 -16
View File
@@ -16,36 +16,23 @@ cmd_restore :: proc(cmd: ^Command) {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false) fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return return
} }
resolved_path, host, remote_path, is_remote, path_ok := normalize_remote_identity(path)
if is_remote && !path_ok {
return
}
if !is_remote {
abs_path, abs_err := filepath.abs(path, context.temp_allocator) abs_path, abs_err := filepath.abs(path, context.temp_allocator)
if abs_err != nil { if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
resolved_path = abs_path
}
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
defer db_close(&db) defer db_close(&db)
file, fetch_ok := db_fetch(&db, resolved_path) file, fetch_ok := db_fetch(&db, abs_path)
if !fetch_ok { if !fetch_ok {
return return
} }
if is_remote {
if !write_remote_file(host, remote_path, file.contents, file.sha256, cmd.flags.force) {
return
}
fmt.wprintf(cmd.out, "Restored %s\n", file.path, flush = false)
return
}
dir := filepath.dir(file.path) dir := filepath.dir(file.path)
if err := os.mkdir_all(dir); err != nil { if err := os.mkdir_all(dir); err != nil {
+22 -8
View File
@@ -4,9 +4,10 @@ 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.flags.config_file) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
@@ -71,7 +72,11 @@ 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(cmd.out, colorize(.Message, "Cancelled."), flush = false) fmt.wprintln(
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "Cancelled." + ANSI_RESET,
flush = false,
)
return return
} }
@@ -95,16 +100,25 @@ cmd_scan :: proc(cmd: ^Command) {
} }
if added_count > 0 { if added_count > 0 {
fmt.wprintln( fmt.wprintf(
cmd.out, cmd.out,
colorize( ansi.CSI +
.Sucess, ansi.BOLD +
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(cmd.out, colorize(.Message, "No files were added."), flush = false) fmt.wprintln(
cmd.out,
ansi.CSI + ansi.FAINT + ansi.SGR + "No files were added." + ANSI_RESET,
flush = false,
)
} }
} }
+4 -4
View File
@@ -11,7 +11,7 @@ SyncEntry :: struct {
// TODO: Check for quiet failures. // TODO: Check for quiet failures.
cmd_sync :: proc(cmd: ^Command) { cmd_sync :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.flags.config_file) db, db_ok := db_open(cmd.config_path)
if !db_ok { if !db_ok {
return return
} }
@@ -46,7 +46,7 @@ cmd_sync :: proc(cmd: ^Command) {
} }
} }
if cmd.flags.output == .Table { if get_format(cmd) == .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)
@@ -54,8 +54,8 @@ cmd_sync :: proc(cmd: ^Command) {
table.aligned_header_of_values( table.aligned_header_of_values(
&t, &t,
.Center, .Center,
colorize(.Table_Heading, "File"), COLOR_TABLE_HEADING + "File" + ANSI_RESET,
colorize(.Table_Heading, "Status"), COLOR_TABLE_HEADING + "Status" + ANSI_RESET,
) )
for res in results { for res in results {
-64
View File
@@ -1,64 +0,0 @@
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
@@ -1,97 +0,0 @@
#+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",
)
}
-157
View File
@@ -1,157 +0,0 @@
package main
import "core:fmt"
import "core:io"
import "core:os"
import "core:path/filepath"
import "core:strconv"
import "core:strings"
import "core:terminal"
cmd_view :: proc(cmd: ^Command) -> bool {
if len(cmd.args) > 1 {
print_command_help(cmd)
return false
}
path := len(cmd.args) == 1 ? cmd.args[0] : ""
db := db_open(cmd.flags.config_file) or_return
defer db_close(&db)
files := db_list(&db) or_return
paths := make([]string, len(files), context.temp_allocator)
for file, i in files do paths[i] = file.path
resolved_path := choose_view_path(path, paths, cmd.err) or_return
file := db_fetch(&db, resolved_path) or_return
if cmd.flags.stdout || !terminal.is_terminal(os.stdout) {
fmt.wprintf(cmd.out, "%s", file.contents, flush = false)
return true
}
page_file_contents(file.contents, cmd.err)
return true
}
choose_view_path :: proc(path: string, paths: []string, err_out: io.Writer) -> (string, bool) {
if len(strings.trim_space(path)) > 0 {
resolved, _, _, is_remote, path_ok := normalize_remote_identity(path)
if is_remote && !path_ok do return "", false
if !is_remote {
abs_path, abs_err := filepath.abs(path, context.temp_allocator)
if abs_err == nil do resolved = abs_path
}
for stored in paths {
if stored == resolved do return stored, true
}
if number, number_ok := strconv.parse_int(path, 10);
number_ok && number > 0 && number <= len(paths) {
return paths[number - 1], true
}
}
matches := matching_view_paths(path, paths)
if len(matches) == 0 {
fmt.wprintf(err_out, "No backed-up file matches: %s\n", path, flush = false)
return "", false
}
if len(path) > 0 && len(matches) == 1 {
return matches[0], true
}
if !terminal.is_terminal(os.stdin) || !terminal.is_terminal(os.stderr) {
fmt.wprintln(
err_out,
"An interactive terminal is required to choose a backup; pass a more specific path.",
flush = false,
)
return "", false
}
selected := single_select("Select a backed-up file:", matches[:])
if selected < 0 {
return "", false
}
return matches[selected], true
}
matching_view_paths :: proc(path: string, paths: []string) -> [dynamic]string {
matches := make([dynamic]string, 0, len(paths), context.temp_allocator)
needle := strings.to_lower(path, context.temp_allocator)
for stored in paths {
if strings.contains(strings.to_lower(stored, context.temp_allocator), needle) {
append(&matches, stored)
}
}
return matches
}
page_file_contents :: proc(contents: string, err_out: io.Writer) {
pager := os.get_env("PAGER", context.temp_allocator)
if len(strings.trim_space(pager)) == 0 {
when ODIN_OS == .Windows {
pager = "more"
} else {
pager = "less"
}
}
parts := strings.split(strings.trim_space(pager), " ", context.temp_allocator)
command := make([dynamic]string, 0, len(parts) + 1, context.temp_allocator)
for part in parts {
if len(part) > 0 {
append(&command, part)
}
}
if len(command) == 0 {
append(&command, "less")
}
when ODIN_OS == .Windows {
if command[0] != "more" do append(&command, "-")
} else {
append(&command, "-")
}
read_end, write_end, pipe_err := os.pipe()
if pipe_err != nil {
fmt.wprintf(err_out, "Error creating pager pipe: %v\n", pipe_err, flush = false)
return
}
defer os.close(write_end)
desc := os.Process_Desc {
command = command[:],
stdin = read_end,
stdout = os.stdout,
stderr = os.stderr,
}
process, start_err := os.process_start(desc)
os.close(read_end)
if start_err != nil {
fmt.wprintf(err_out, "Error starting pager %q: %v\n", command[0], start_err, flush = false)
return
}
remaining := transmute([]u8)contents
write_err: os.Error
for len(remaining) > 0 {
written, err := os.write(write_end, remaining)
if err != nil {
write_err = err
break
}
if written == 0 do break
remaining = remaining[written:]
}
os.close(write_end)
state, wait_err := os.process_wait(process)
if write_err != nil {
fmt.wprintf(err_out, "Error writing to pager: %v\n", write_err, flush = false)
}
if wait_err != nil {
fmt.wprintf(err_out, "Error waiting for pager: %v\n", wait_err, flush = false)
} else if state.exit_code != 0 {
fmt.wprintf(err_out, "Pager exited with status %d\n", state.exit_code, flush = false)
}
}
-158
View File
@@ -1,158 +0,0 @@
#+test
package main
import "core:bufio"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:terminal"
import "core:testing"
@(test)
test_cmd_view_writes_decrypted_backup_to_stdout :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-view-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join({base, "config.json"}, context.temp_allocator)
file_path, _ := filepath.join({base, ".env"}, context.temp_allocator)
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "config should save")
}
{
db, db_ok := db_open(cfg_path)
defer db_close(&db)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
file := make_test_env_file(file_path, "abc123", "SECRET=value\n")
testing.expect(t, db_insert(&db, file), "backup should insert")
}
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "view", file_path, "--stdout", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "view command should parse")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, cmd_view(&cmd), "stored backup should be viewable without local file")
bufio.writer_flush(cmd.out_buf)
testing.expect_value(t, strings.to_string(out_b), "SECRET=value\n")
testing.expect_value(t, len(strings.to_string(err_b)), 0)
}
@(test)
test_cmd_view_number_follows_list_order :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-view-number-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join({base, "config.json"}, context.temp_allocator)
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "config should save")
}
{
db, db_ok := db_open(cfg_path)
defer db_close(&db)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
paths := []string{"/zeta/.env", "/alpha/.env"}
for path, i in paths {
contents := i == 0 ? "ZETA\n" : "ALPHA\n"
file := make_test_env_file(path, "abc123", contents)
testing.expect(t, db_insert(&db, file), "backup should insert")
delete(file.remotes)
}
}
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok := parse_args(
[]string{"envr", "view", "1", "--stdout", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "view command should parse")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, cmd_view(&cmd), "first list position should resolve")
bufio.writer_flush(cmd.out_buf)
testing.expect_value(t, strings.to_string(out_b), "ALPHA\n")
}
@(test)
test_view_path_matches :: proc(t: ^testing.T) {
paths := []string{"/projects/api/.env", "/projects/web/.env", "user@host:/etc/service.env"}
matches := matching_view_paths("WEB", paths)
testing.expect_value(t, len(matches), 1)
if len(matches) > 0 do testing.expect_value(t, matches[0], paths[1])
remote := matching_view_paths("host:/etc", paths)
testing.expect_value(t, len(remote), 1)
if len(remote) > 0 do testing.expect_value(t, remote[0], paths[2])
}
@(test)
test_view_path_unique_and_missing :: proc(t: ^testing.T) {
paths := []string{"/projects/api/.env", "/projects/web/.env", "/projects/2"}
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
w := strings.to_writer(&err_b)
path, ok := choose_view_path("api", paths, w)
testing.expect(t, ok, "unique substring should resolve")
testing.expect_value(t, path, paths[0])
path, ok = choose_view_path("/projects/web/.env", paths, w)
testing.expect(t, ok, "exact stored path should resolve")
testing.expect_value(t, path, paths[1])
path, ok = choose_view_path("2", paths, w)
testing.expect(t, ok, "list position should resolve")
testing.expect_value(t, path, paths[1])
testing.expect(
t,
path != paths[2],
"list position should take priority over a partial path match",
)
path, ok = choose_view_path("3", paths, w)
testing.expect(t, ok, "third list position should resolve")
testing.expect_value(t, path, paths[2])
_, ok = choose_view_path("0", paths, w)
testing.expect(t, !ok, "zero is not a valid list position")
_, ok = choose_view_path("missing", paths, w)
testing.expect(t, !ok, "missing backup should fail")
testing.expect(
t,
strings.contains(strings.to_string(err_b), "No backed-up file matches"),
"missing backup should explain failure",
)
}
@(test)
test_view_picker_requires_terminal :: proc(t: ^testing.T) {
if terminal.is_terminal(os.stdin) && terminal.is_terminal(os.stderr) do return
paths := []string{"/projects/api/.env", "/projects/web/.env"}
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
_, no_path_ok := choose_view_path("", paths, strings.to_writer(&err_b))
testing.expect(t, !no_path_ok, "no path should require an interactive picker")
_, ambiguous_ok := choose_view_path("projects", paths, strings.to_writer(&err_b))
testing.expect(t, !ambiguous_ok, "ambiguous path should require an interactive picker")
}
+9 -58
View File
@@ -1,66 +1,17 @@
package main package main
import "base:runtime"
import "core:strings"
import "core:terminal/ansi" import "core:terminal/ansi"
Color_Code :: enum { COLOR_HEADINGS ::
Heading, ansi.CSI + ansi.FG_BRIGHT_GREEN + ";" + ansi.BOLD + ";" + ansi.UNDERLINE + ansi.SGR
Command,
// Example
Flag,
Table_Heading,
Message,
Sucess,
Caret,
Option_Label,
}
// COLOR_EXAMPLE :: ansi.CSI + ansi.ITALIC + ansi.SGR COLOR_COMMANDS :: ansi.CSI + ansi.FG_BRIGHT_CYAN + ";" + ansi.BOLD + 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")
}
}
+8 -10
View File
@@ -201,7 +201,8 @@ find_git_roots :: proc(
ok: bool, ok: bool,
) { ) {
paths := search_paths(cfg, allocator) paths := search_paths(cfg, allocator)
findr.find_repos(paths[:], &roots, os.get_processor_core_count(), allocator) // TODO: Pass allocator to findr
findr.find_repos(paths[:], &roots, os.get_processor_core_count())
ok = true ok = true
return return
} }
@@ -213,7 +214,6 @@ 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)
@@ -221,15 +221,17 @@ search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]st
include = expanded include = expanded
} else { } else {
// TODO: show errors? // TODO: show errors?
resolved: string resolved, err := filepath.abs(expanded, allocator)
resolved, err = filepath.abs(expanded, allocator)
if err == nil { if err == nil {
include = resolved include = resolved
} }
} }
} }
result := paths^ return paths^
return result }
envr_dir :: proc(config_path: string) -> string {
return filepath.dir(config_path)
} }
// User is responsible for freeing the path // User is responsible for freeing the path
@@ -243,7 +245,3 @@ data_path :: proc(
return filepath.join([]string{envr_dir(config_path), "data.envr"}, allocator) return filepath.join([]string{envr_dir(config_path), "data.envr"}, allocator)
} }
envr_dir :: proc(config_path: string) -> string {
return filepath.dir(config_path)
}
-12
View File
@@ -2,7 +2,6 @@
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"
@@ -195,14 +194,3 @@ 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" import "core:os"
MAGIC :: "ENVR" MAGIC :: "ENVR"
MAGIC_BYTES: [4]u8 = MAGIC MAGIC_BYTES := [4]u8{u8('E'), u8('N'), u8('V'), u8('R')}
RECIPIENT_ENTRY_SIZE :: RECIPIENT_ENTRY_SIZE ::
CRYPTO_BOX_PUBLICKEY_BYTES + CRYPTO_BOX_PUBLICKEY_BYTES +
+64 -291
View File
@@ -1,13 +1,14 @@
package main package main
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"
import "core:path/filepath" import "core:path/filepath"
import "core:path/slashpath"
import "core:strings" import "core:strings"
import "sqlite" import "sqlite"
@@ -20,12 +21,6 @@ SyncFlagEnum :: enum {
SyncFlag :: bit_set[SyncFlagEnum] SyncFlag :: bit_set[SyncFlagEnum]
Remote_Read_Status :: enum {
Ok,
Missing,
Failed,
}
SyncError :: enum { SyncError :: enum {
None, None,
DirMissing, DirMissing,
@@ -177,7 +172,7 @@ db_close :: proc(db: ^Db) {
} }
sz: i64 sz: i64
data := sqlite.serialize(db.conn, "main", &sz, {}) data := sqlite.serialize(db.conn, "main", &sz, 0)
if data == nil { if data == nil {
fmt.eprintln("Error: failed to serialize database") fmt.eprintln("Error: failed to serialize database")
return return
@@ -213,7 +208,7 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
stmt: sqlite.Stmt stmt: sqlite.Stmt
rc := sqlite.prepare_v2( rc := sqlite.prepare_v2(
db.conn, db.conn,
"SELECT path, remotes, sha256, contents FROM envr_env_files ORDER BY path COLLATE BINARY", "SELECT path, remotes, sha256, contents FROM envr_env_files",
-1, -1,
&stmt, &stmt,
nil, nil,
@@ -227,6 +222,7 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
allocator := db_allocator(db) allocator := db_allocator(db)
results := make([dynamic]EnvFile, 0, 10, allocator) results := make([dynamic]EnvFile, 0, 10, allocator)
migrate := false
for { for {
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc == sqlite.DONE { if rc == sqlite.DONE {
@@ -237,13 +233,24 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
#no_bounds_check return results[:], false #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)) 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) split := strings.split_lines(remotes_raw, context.temp_allocator)
remotes := make([dynamic]string, 0, len(split), allocator = allocator) remotes = make([dynamic]string, 0, len(split), allocator = allocator)
for s in split { for s in split {
append(&remotes, strings.clone(s, allocator)) append(&remotes, strings.clone(s, allocator))
} }
}
}
path := clone_cstring(sqlite.column_text(stmt, 0), allocator) path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
append( append(
@@ -258,6 +265,10 @@ db_list :: proc(db: ^Db) -> ([]EnvFile, bool) {
) )
} }
if migrate {
migrate_remotes(db)
}
#no_bounds_check return results[:], true #no_bounds_check return results[:], true
} }
@@ -323,11 +334,7 @@ db_insert :: proc(db: ^Db, file: EnvFile) -> bool {
// //
// Expects an absolute path // Expects an absolute path
db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) { db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
if _, remote_path, is_remote := parse_remote_path(path); is_remote {
assert(slashpath.is_abs(remote_path))
} else {
assert(os.is_absolute_path(path)) assert(os.is_absolute_path(path))
}
sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?" sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?"
stmt: sqlite.Stmt stmt: sqlite.Stmt
@@ -357,15 +364,33 @@ db_fetch :: proc(db: ^Db, path: string) -> (EnvFile, bool) {
return EnvFile{}, false return EnvFile{}, false
} }
// TODO: Remove json support after next major release
migrate := false
remotes: [dynamic]string = ---
remotes_raw := string(sqlite.column_text(stmt, 1)) 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) split := strings.split_lines(remotes_raw, context.temp_allocator)
remotes := make([dynamic]string, 0, len(split), allocator = allocator) remotes = make([dynamic]string, 0, len(split), allocator = allocator)
for s in split { for s in split {
append(&remotes, strings.clone(s, allocator)) append(&remotes, strings.clone(s, allocator))
} }
}
}
file_path := clone_cstring(sqlite.column_text(stmt, 0), allocator) file_path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
if migrate {
migrate_remotes(db)
}
return EnvFile { return EnvFile {
path = file_path, path = file_path,
dir = filepath.dir(file_path), dir = filepath.dir(file_path),
@@ -410,17 +435,6 @@ db_delete :: proc(db: ^Db, path: string) -> bool {
// Caller is responsible for the returned memory // Caller is responsible for the returned memory
new_env_file :: proc(path: string) -> (EnvFile, bool) { new_env_file :: proc(path: string) -> (EnvFile, bool) {
if identity, host, remote_path, is_remote, resolved := normalize_remote_identity(path); is_remote {
if !resolved {
return EnvFile{}, false
}
contents, read_status := read_remote_file(host, remote_path)
if read_status != .Ok {
return EnvFile{}, false
}
return new_remote_env_file(identity, contents), true
}
abs_path, abs_err := filepath.abs(path) abs_path, abs_err := filepath.abs(path)
if abs_err != nil { if abs_err != nil {
fmt.eprintf("Error getting absolute path: %v\n", abs_err) fmt.eprintf("Error getting absolute path: %v\n", abs_err)
@@ -450,66 +464,12 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
true true
} }
// Takes ownership of contents and returns an EnvFile that owns all its fields.
new_remote_env_file :: proc(path: string, contents: []byte) -> EnvFile {
digest := hash.hash_bytes(hash.Algorithm.SHA256, contents, context.temp_allocator)
hex_bytes := hex.encode(digest, context.allocator)
return EnvFile {
path = strings.clone(path, context.allocator),
dir = "",
sha256 = string(hex_bytes),
contents = string(contents),
}
}
// Reconciles `f` with the filesystem and persists changes to the database. // Reconciles `f` with the filesystem and persists changes to the database.
db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) { db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
allocator := db_allocator(db) allocator := db_allocator(db)
result: SyncFlag = {} result: SyncFlag = {}
old_path := f.path old_path := f.path
if _, _, is_remote := parse_remote_path(f.path); is_remote {
identity, host, remote_path, _, resolved := normalize_remote_identity(f.path)
if !resolved {
return {}, .ReadFailed
}
contents, read_status := read_remote_file(host, remote_path)
if read_status == .Failed {
return {}, .ReadFailed
}
if read_status == .Missing {
if !write_remote_file(host, remote_path, f.contents, f.sha256, false) {
return {}, .WriteFailed
}
return {.Restored}, .None
}
digest := hash.hash_bytes(hash.Algorithm.SHA256, contents, context.temp_allocator)
hex_bytes := hex.encode(digest, allocator)
current_sha := string(hex_bytes)
if current_sha == f.sha256 {
delete(contents)
if identity != old_path {
f.path = strings.clone(identity, allocator)
if !db_persist(db, f, old_path) {
return {}, .DbFailed
}
}
return {}, .None
}
if identity != old_path {
f.path = strings.clone(identity, allocator)
}
f.contents = string(contents)
f.sha256 = current_sha
if !db_persist(db, f, old_path) {
return {}, .DbFailed
}
return {.BackedUp}, .None
}
if !os.exists(f.dir) { if !os.exists(f.dir) {
moved, err := try_move_dir(db, f, allocator) moved, err := try_move_dir(db, f, allocator)
if !moved { if !moved {
@@ -556,215 +516,6 @@ db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
return result + {.BackedUp}, .None return result + {.BackedUp}, .None
} }
// A remote identity uses user@host:path; CLI input is expanded to an absolute path.
parse_remote_path :: proc(path: string) -> (host, remote_path: string, is_remote: bool) {
separator := strings.index(path, ":")
if separator <= 0 || separator + 1 >= len(path) {
return
}
host = path[:separator]
if strings.index(host, "@") <= 0 ||
strings.index(host, " ") >= 0 ||
strings.index(host, "'") >= 0 {
return "", "", false
}
remote_path = path[separator + 1:]
is_remote = true
return
}
// Resolves relative and tilde-prefixed remote input against the remote HOME.
// Absolute paths stay absolute, independent of the local host OS.
normalize_remote_identity :: proc(path: string) -> (
identity, host, remote_path: string,
is_remote, ok: bool,
) {
parsed_host, parsed_path, parsed_is_remote := parse_remote_path(path)
if !parsed_is_remote {
return path, "", "", false, true
}
host = parsed_host
is_remote = true
if slashpath.is_abs(parsed_path) {
remote_path = slashpath.clean(parsed_path, context.temp_allocator)
} else {
home, home_ok := remote_home_directory(host)
if !home_ok {
return "", host, "", true, false
}
remote_path = expand_remote_path(home, parsed_path)
}
identity = fmt.tprintf("%s:%s", host, remote_path)
ok = true
return
}
// Expands relative and tilde-prefixed remote input against an absolute POSIX home.
expand_remote_path :: proc(home, path: string) -> string {
if slashpath.is_abs(path) {
return slashpath.clean(path, context.temp_allocator)
}
relative := path
if strings.has_prefix(relative, "~") {
relative = relative[1:]
if strings.has_prefix(relative, "/") {
relative = relative[1:]
}
}
return slashpath.join({home, relative}, context.temp_allocator)
}
remote_home_directory :: proc(host: string) -> (home: string, ok: bool) {
// The command is constant; HOME is expanded by the remote shell and no
// user-supplied path is interpolated into it.
desc := os.Process_Desc {
command = []string{"ssh", "-T", "--", host, "printf '%s' \"$HOME\""},
}
state, stdout, stderr, err := os.process_exec(desc, context.temp_allocator)
delete(stderr)
if err != nil {
fmt.eprintf("Error querying remote home directory over ssh: %v\n", err)
delete(stdout)
return "", false
}
if !state.success {
fmt.eprintf("Error querying remote home directory over ssh (exit code %d)\n", state.exit_code)
delete(stdout)
return "", false
}
home = string(stdout)
if !slashpath.is_abs(home) {
fmt.eprintf("Error: remote HOME is not an absolute POSIX path\n")
return "", false
}
home = slashpath.clean(home, context.temp_allocator)
ok = true
return
}
// Read a remote file through OpenSSH. The path is quoted for the remote shell;
// the host and command are passed as distinct local argv elements.
read_remote_file :: proc(host, remote_path: string) -> (contents: []byte, status: Remote_Read_Status) {
quoted_path := quote_remote_shell_path(remote_path)
remote_command := fmt.tprintf("if [ -e '%s' ]; then cat -- '%s'; else exit 44; fi", quoted_path, quoted_path)
desc := os.Process_Desc {
command = []string{"ssh", "-T", "--", host, remote_command},
stdin = nil,
}
state, stdout, stderr, err := os.process_exec(desc, context.allocator)
if err != nil {
fmt.eprintf("Error starting ssh: %v\n", err)
delete(stdout)
delete(stderr)
return nil, .Failed
}
delete(stderr)
if !state.success {
if state.exit_code == 44 {
delete(stdout)
return nil, .Missing
}
fmt.eprintf("Error reading remote file via ssh (exit code %d)\n", state.exit_code)
delete(stdout)
return nil, .Failed
}
return stdout, .Ok
}
quote_remote_shell_path :: proc(path: string) -> string {
quoted_path, _ := strings.replace_all(path, "'", "'\"'\"'", context.temp_allocator)
return quoted_path
}
// Atomically writes a remote file via SSH stdin, never staging plaintext locally.
// Existing remote contents must match the stored digest unless force is set.
write_remote_file :: proc(host, remote_path, contents, expected_sha: string, force: bool) -> bool {
if len(expected_sha) != 64 {
fmt.eprintf("Error: invalid stored SHA-256 digest for remote restore\n")
return false
}
for c in expected_sha {
if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') && !(c >= 'A' && c <= 'F') {
fmt.eprintf("Error: invalid stored SHA-256 digest for remote restore\n")
return false
}
}
quoted_path := quote_remote_shell_path(remote_path)
force_check := force ? ":" : fmt.tprintf(`
if [ -e "$target" ]; then
if command -v sha256sum >/dev/null 2>&1; then
current=$(sha256sum -- "$target" | cut -d ' ' -f 1)
elif command -v shasum >/dev/null 2>&1; then
current=$(shasum -a 256 -- "$target" | cut -d ' ' -f 1)
else
echo 'Remote restore requires sha256sum or shasum; use --force to bypass conflict checking' >&2
exit 74
fi
if [ "$current" != '%s' ]; then
echo 'Remote file changed since backup; use --force to overwrite' >&2
exit 73
fi
fi
`, expected_sha)
remote_command := fmt.tprintf(`
set -eu
target='%s'
dir=${target%/*}
[ -n "$dir" ] || dir=/
mkdir -p -- "$dir"
umask 077
tmp=$(mktemp "$dir/.envr.XXXXXXXX")
trap 'rm -f -- "$tmp"' EXIT HUP INT TERM
cat > "$tmp"
%s
mv -f -- "$tmp" "$target"
trap - EXIT HUP INT TERM
`, quoted_path, force_check)
read_pipe, write_pipe, pipe_err := os.pipe()
if pipe_err != nil {
fmt.eprintf("Error creating SSH input pipe: %v\n", pipe_err)
return false
}
desc := os.Process_Desc {
command = []string{"ssh", "-T", "--", host, remote_command},
stdin = read_pipe,
}
process, start_err := os.process_start(desc)
_ = os.close(read_pipe)
if start_err != nil {
_ = os.close(write_pipe)
fmt.eprintf("Error starting ssh: %v\n", start_err)
return false
}
remaining := transmute([]byte)contents
write_err: os.Error
for len(remaining) > 0 {
written, err := os.write(write_pipe, remaining)
if err != nil {
write_err = err
break
}
if written == 0 {
break
}
remaining = remaining[written:]
}
_ = os.close(write_pipe)
state, wait_err := os.process_wait(process)
if write_err != nil {
fmt.eprintf("Error sending file contents over ssh: %v\n", write_err)
return false
}
if wait_err != nil || !state.success {
fmt.eprintf("Error writing remote file via ssh (exit code %d)\n", state.exit_code)
return false
}
return true
}
db_persist :: proc(db: ^Db, f: ^EnvFile, old_path: string) -> bool { db_persist :: proc(db: ^Db, f: ^EnvFile, old_path: string) -> bool {
if f.path != old_path { if f.path != old_path {
if !db_delete(db, old_path) { if !db_delete(db, old_path) {
@@ -774,8 +525,29 @@ db_persist :: proc(db: ^Db, f: ^EnvFile, old_path: string) -> bool {
return db_insert(db, f^) 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) { try_move_dir :: proc(db: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, SyncError) {
roots, ok := find_git_roots(db.cfg, context.allocator) roots, ok := find_git_roots(db.cfg)
if !ok { if !ok {
return false, .GitRootFailed return false, .GitRootFailed
} }
@@ -876,3 +648,4 @@ clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string {
return str return str
} }
+1 -68
View File
@@ -7,7 +7,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:sys/posix"
import "core:testing" import "core:testing"
import "sqlite" import "sqlite"
@@ -197,7 +196,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, {}) data := sqlite.serialize(db.conn, "main", &sz, 0)
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)
@@ -560,69 +559,3 @@ 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")
}
+10 -12
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 easy way to gather all your .env files in one place where they can Is a safe and eay 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,27 +33,25 @@ 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 ~/&lt;path to repository&gt;/.env
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for envr
``` ```
### 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 view](envr_view.md) - View a backed-up .env file
* [envr version](envr_version.md) - Show envr's version * [envr version](envr_version.md) - Show envr's version
* [envr edit-config](envr_edit-config.md) - Edit your config with your default editor
* [envr completion](envr_completion.md) - Generate shell completion scripts
* [envr uninstall](envr_uninstall.md) - Remove envr configuration and database
+2 -9
View File
@@ -6,20 +6,13 @@ 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 show this documentation -h, --help help for backup
-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.
+3 -8
View File
@@ -1,23 +1,18 @@
## envr check ## envr check
Check if files are backed up check if files in the current directory 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 show this documentation -h, --help help for check
-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.
-26
View File
@@ -1,26 +0,0 @@
## envr completion
Generate shell completion scripts
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.
+2 -3
View File
@@ -9,11 +9,10 @@ envr edit-config [flags]
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for edit-config
-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.
+8 -5
View File
@@ -2,8 +2,12 @@
Set up envr Set up envr
### Synopsis
The init command generates your initial config and saves it to The init command generates your initial config and saves it to
~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to ~/.envr/config in JSON format.
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.
@@ -14,12 +18,11 @@ envr init [flags]
### Options ### Options
``` ```
-h, --help show this documentation -f, --force Overwrite an existing config
-c, --config-file config file (default "~/.envr/config.json") -h, --help help for init
--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.
+2 -6
View File
@@ -2,8 +2,6 @@
View your tracked files View your tracked files
Table output is sorted by stored path and numbered from 1. Use 'envr view <number>' to view the contents of a backup.
``` ```
envr list [flags] envr list [flags]
``` ```
@@ -11,12 +9,10 @@ envr list [flags]
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for list
-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
@@ -0,0 +1,23 @@
## 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.
+3 -8
View File
@@ -3,21 +3,16 @@
Remove a .env file from your database Remove a .env file from your database
``` ```
envr remove <path> [flags] envr remove [flags]
``` ```
### Arguments
* `path` — Path to .env file to remove
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for remove
-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 -14
View File
@@ -1,28 +1,18 @@
## envr restore ## envr restore
Restore a .env file from the database Install a .env file from the database into your file system
``` ```
envr restore <path> [flags] envr restore [flags]
``` ```
### Arguments
* `path` — Path to .env file to restore
Remote files are restored over SSH using an atomic replacement. If the remote
file has changed since it was backed up, restoration is refused unless
`--force` is specified.
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for restore
-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.
+2 -3
View File
@@ -9,11 +9,10 @@ envr scan [flags]
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for scan
-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.
+2 -8
View File
@@ -2,10 +2,6 @@
Update or restore your env backups Update or restore your env backups
For both local and remote files, `sync` backs up changed file contents and
restores missing files from the backup. Remote SSH and permission errors are
reported instead of being treated as missing files.
``` ```
envr sync [flags] envr sync [flags]
``` ```
@@ -13,12 +9,10 @@ envr sync [flags]
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for sync
-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.
-22
View File
@@ -1,22 +0,0 @@
## envr uninstall
Remove envr configuration and database
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.
+3 -1
View File
@@ -9,9 +9,11 @@ envr version [flags]
### Options ### Options
``` ```
-h, --help show this documentation -h, --help help for version
-l, --long Show all version information
``` ```
### SEE ALSO ### SEE ALSO
* [envr](envr.md) - Manage your .env files. * [envr](envr.md) - Manage your .env files.
-26
View File
@@ -1,26 +0,0 @@
## envr view
View a backed-up .env file
If no path is provided, the user will be asked to select one from a list. You may also pass the number from the "#" column of `envr list` instead of the full path. If a partial path is passed, the closest match will be opened.
```
envr view [path] [flags]
```
### Arguments
* `path` — Path or substring, or number of a backed-up file. (optional)
### Options
```
-h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
-p, --stdout Print file contents directly to stdout instead of using a pager
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+1 -9
View File
@@ -16,14 +16,7 @@ RepoPool :: struct {
threads: []^thread.Thread, threads: []^thread.Thread,
} }
find_repos :: proc( find_repos :: proc(roots: []string, results: ^[dynamic]string, thread_count: int) {
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)
@@ -133,4 +126,3 @@ process_repo_dir :: proc(pool: ^RepoPool, dir_path: string) {
} }
} }
} }
+1 -9
View File
@@ -78,15 +78,7 @@ collect_worker :: proc(t: ^thread.Thread) {
} }
} }
walk :: proc( walk :: proc(roots: []string, results: ^[dynamic]string, opts: WalkOptions, thread_count: int) {
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)
-139
View File
@@ -1,139 +0,0 @@
package main
import "base:runtime"
import "core:reflect"
import "core:strings"
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[:]
}
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
}
+1 -17
View File
@@ -66,19 +66,15 @@
packages.default = pkgs.stdenv.mkDerivation rec { packages.default = pkgs.stdenv.mkDerivation rec {
pname = "envr"; pname = "envr";
version = nixpkgs.lib.trim (builtins.readFile ./version.txt); version = "0.3.0";
src = ./.; src = ./.;
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.unstable.odin pkgs.unstable.odin
pkgs.pkg-config pkgs.pkg-config
pkgs.makeWrapper
]; ];
buildInputs = [ buildInputs = [
pkgs.git
pkgs.openssh
pkgs.libsodium pkgs.libsodium
mysqlite mysqlite
]; ];
@@ -93,11 +89,6 @@
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
''; '';
@@ -105,15 +96,8 @@
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
''; '';
postFixup = ''
wrapProgram $out/bin/envr --prefix PATH : ${nixpkgs.lib.makeBinPath [ pkgs.openssh ]}
'';
}; };
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
-287
View File
@@ -1,287 +0,0 @@
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.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
@@ -1,100 +0,0 @@
_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
+2 -8
View File
@@ -14,7 +14,6 @@ when SPALL {
spall_buffer: spall.Buffer spall_buffer: spall.Buffer
} }
when !#config(GENDOCS, false) {
main :: proc() { main :: proc() {
when SPALL { when SPALL {
ctx, spall_ok := spall.context_create_with_scale("envr.spall", false, 1.0) ctx, spall_ok := spall.context_create_with_scale("envr.spall", false, 1.0)
@@ -74,23 +73,18 @@ when !#config(GENDOCS, false) {
cmd_edit_config(&cmd) cmd_edit_config(&cmd)
case "check": case "check":
cmd_check(&cmd) cmd_check(&cmd)
case "view":
if !cmd_view(&cmd) do os.exit(1)
case "scan": case "scan":
cmd_scan(&cmd) cmd_scan(&cmd)
case "sync": case "sync":
cmd_sync(&cmd) cmd_sync(&cmd)
case "completion": case "nushell-completion":
cmd_completion(&cmd) cmd_nushell_completion(&cmd)
case "uninstall":
cmd_uninstall(&cmd)
case: case:
fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name) fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
write_usage(cmd.out) write_usage(cmd.out)
os.exit(1) os.exit(1)
} }
} }
} // !GENDOCS
when SPALL { when SPALL {
@(instrumentation_enter) @(instrumentation_enter)
+71
View File
@@ -0,0 +1,71 @@
# 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
]
+29 -158
View File
@@ -1,8 +1,6 @@
package main package main
import "core:fmt" import "core:fmt"
import "core:os"
import "core:strings"
import "core:sys/posix" import "core:sys/posix"
import "core:terminal/ansi" import "core:terminal/ansi"
@@ -17,8 +15,6 @@ Key :: enum {
Space, Space,
Enter, Enter,
Escape, Escape,
Backspace,
Char,
Unknown, Unknown,
} }
@@ -29,71 +25,6 @@ Raw_State :: struct {
MAX_VISIBLE :: 7 MAX_VISIBLE :: 7
// Returns the index of the chosen option, or -1 on cancellation or terminal error.
single_select :: proc(prompt: string, options: []string) -> int {
if len(options) == 0 do return -1
filter: [dynamic]u8
defer delete(filter)
filtered: [dynamic]int
defer delete(filtered)
rebuild_filtered(options, filter[:], &filtered)
raw, ok := enable_raw_mode(posix.STDIN_FILENO)
if !ok do return -1
defer disable_raw_mode(&raw)
cursor, scroll_offset := 0, 0
fmt.fprintf(os.stderr, ansi.CSI + ansi.DECTCEM_HIDE)
visible := render_single_options(prompt, options, filtered[:], "", cursor, scroll_offset)
defer fmt.fprintf(os.stderr, ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, visible + 1)
for {
key, ch := read_key()
switch key {
case .Char:
append(&filter, u8(ch))
rebuild_filtered(options, filter[:], &filtered)
cursor = clamp(cursor, 0, max(0, len(filtered) - 1))
case .Backspace:
if len(filter) > 0 {
pop(&filter)
rebuild_filtered(options, filter[:], &filtered)
cursor = clamp(cursor, 0, max(0, len(filtered) - 1))
}
case .Up:
if cursor > 0 do cursor -= 1
case .Down:
if cursor < len(filtered) - 1 do cursor += 1
case .Enter:
if len(filtered) > 0 do return filtered[cursor]
case .Escape:
return -1
case .Unknown:
return -1
case .Space:
}
scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(filtered) - MAX_VISIBLE))
fmt.fprintf(os.stderr, ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.RESET + ansi.ED, visible + 1)
visible = render_single_options(prompt, options, filtered[:], string(filter[:]), cursor, scroll_offset)
}
}
render_single_options :: proc(prompt: string, options: []string, filtered: []int, filter_text: string, cursor, scroll_offset: int) -> int {
fmt.fprintf(os.stderr, "%s (type to filter, ↑/↓ move, enter select, esc cancel)\r\n", prompt)
fmt.fprintf(os.stderr, "filter: %s\r\n", filter_text)
if len(filtered) == 0 {
fmt.fprintf(os.stderr, " No matches\r\n")
return 2
}
end := min(scroll_offset + MAX_VISIBLE, len(filtered))
for i in scroll_offset ..< end {
fmt.fprintf(os.stderr, "%s %s\r\n", i == cursor ? ">" : " ", options[filtered[i]])
}
return 1 + end - scroll_offset
}
// Caller is responsible for deleting the responses. // Caller is responsible for deleting the responses.
multi_select :: proc( multi_select :: proc(
prompt: string, prompt: string,
@@ -107,17 +38,11 @@ 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[:], filtered[:], string(filter[:]), cursor, scroll_offset) visible := render_options(prompt, options, selected[:], cursor, scroll_offset)
raw, ok := enable_raw_mode(posix.STDIN_FILENO) raw, ok := enable_raw_mode(posix.STDIN_FILENO)
if !ok { if !ok {
@@ -127,63 +52,33 @@ multi_select :: proc(
defer disable_raw_mode(&raw) defer disable_raw_mode(&raw)
for { for {
key, ch := read_key() key := 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(filtered) - 1 { if cursor < len(options) - 1 {
cursor += 1 cursor += 1
} }
case .Space: case .Space:
if len(filtered) > 0 { selected[cursor] = !selected[cursor]
selected[filtered[cursor]] = !selected[filtered[cursor]]
}
case .Enter: case .Enter:
fmt.printf( fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, visible + 1)
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( fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW, visible + 1)
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(filtered) - MAX_VISIBLE)) scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(options) - 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[:], filtered[:], string(filter[:]), cursor, scroll_offset) visible = render_options(prompt, options, selected[:], 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)
}
} }
} }
@@ -191,49 +86,29 @@ 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( fmt.printf(ansi.CSI + ansi.BOLD + ";" + ansi.FG_CYAN + ansi.SGR + "%s" + ANSI_RESET + " (↑/↓ move, space select, enter confirm)\r\n", prompt)
"%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(filtered) { if end > len(options) {
end = len(filtered) end = len(options)
} }
for i in scroll_offset ..< end { for i in scroll_offset ..< end {
original := filtered[i]
checkbox := " " checkbox := " "
if selected[original] { if selected[i] {
checkbox = "x" checkbox = "x"
} }
if i == cursor { if i == cursor {
fmt.printf( 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])
"%s [%s] %s\r\n",
colorize(.Caret, ">"),
colorize(.Sucess, checkbox),
options[original],
)
} else { } else {
fmt.printf(" [%s] %s\r\n", colorize(.Sucess, checkbox), options[original]) fmt.printf(" [" + ansi.CSI + ansi.FAINT + ansi.SGR + "%s" + ANSI_RESET + "] %s\r\n", checkbox, options[i])
} }
} }
return line_count + (end - scroll_offset) return end - scroll_offset
} }
enable_raw_mode :: proc(fd: posix.FD) -> (Raw_State, bool) { enable_raw_mode :: proc(fd: posix.FD) -> (Raw_State, bool) {
@@ -263,23 +138,21 @@ 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: Key, ch: rune) { read_key :: proc() -> Key {
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, 0 return .Unknown
} }
switch buf[0] { switch buf[0] {
case ' ': case ' ':
return .Space, 0 return .Space
case '\n', '\r': case '\n', '\r':
return .Enter, 0 return .Enter
case 0x03: case 0x03:
return .Escape, 0 return .Escape
case 0x08, 0x7F:
return .Backspace, 0
case 0x1b: case 0x1b:
tv: posix.timeval tv: posix.timeval
tv.tv_sec = 0 tv.tv_sec = 0
@@ -291,12 +164,12 @@ read_key :: proc() -> (key: Key, ch: rune) {
ready := posix.select(1, &set, nil, nil, &tv) ready := posix.select(1, &set, nil, nil, &tv)
if ready <= 0 { if ready <= 0 {
return .Escape, 0 return .Escape
} }
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, 0 return .Escape
} }
posix.FD_ZERO(&set) posix.FD_ZERO(&set)
@@ -306,26 +179,24 @@ read_key :: proc() -> (key: Key, ch: rune) {
ready = posix.select(1, &set, nil, nil, &tv) ready = posix.select(1, &set, nil, nil, &tv)
if ready <= 0 { if ready <= 0 {
return .Escape, 0 return .Escape
} }
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, 0 return .Escape
} }
switch buf[2] { switch buf[2] {
case 'A': case 'A':
return .Up, 0 return .Up
case 'B': case 'B':
return .Down, 0 return .Down
case: case:
return .Escape, 0 return .Escape
} }
case: case:
if buf[0] >= 0x20 && buf[0] <= 0x7E { return .Unknown
return .Char, rune(buf[0])
}
return .Unknown, 0
} }
} }
-121
View File
@@ -1,121 +0,0 @@
#+test
package main
import "core:strings"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:testing"
@(test)
test_parse_remote_path :: proc(t: ^testing.T) {
host, path, ok := parse_remote_path("spencer@example.com:/srv/app/.env")
testing.expect(t, ok, "expected remote identity to be recognized")
testing.expect_value(t, host, "spencer@example.com")
testing.expect_value(t, path, "/srv/app/.env")
}
@(test)
test_parse_remote_path_rejects_local_paths :: proc(t: ^testing.T) {
_, _, ok := parse_remote_path("/srv/app/.env")
testing.expect(t, !ok, "local path should not be treated as remote")
_, _, ok = parse_remote_path("user@example.com:")
testing.expect(t, !ok, "remote path must not be empty")
}
@(test)
test_expand_remote_path_to_absolute_posix_path :: proc(t: ^testing.T) {
cases := [?][2]string{
{"outline/docker.env", "/root/outline/docker.env"},
{"~/outline/docker.env", "/root/outline/docker.env"},
{"~outline/docker.env", "/root/outline/docker.env"},
{"/root/outline/docker.env", "/root/outline/docker.env"},
{"../outside.env", "/outside.env"},
}
for c in cases {
got := expand_remote_path("/root", c[0])
testing.expect_value(t, got, c[1])
}
}
@(test)
test_normalize_remote_absolute_identity :: proc(t: ^testing.T) {
identity, host, path, is_remote, ok := normalize_remote_identity(
"root@vertaxdev.com:/root/outline/docker.env",
)
testing.expect(t, is_remote && ok, "absolute remote identity should normalize without SSH")
testing.expect_value(t, identity, "root@vertaxdev.com:/root/outline/docker.env")
testing.expect_value(t, host, "root@vertaxdev.com")
testing.expect_value(t, path, "/root/outline/docker.env")
}
@(test)
test_quote_remote_shell_path :: proc(t: ^testing.T) {
testing.expect_value(t, quote_remote_shell_path("/srv/app/.env"), "/srv/app/.env")
testing.expect_value(t, quote_remote_shell_path("/srv/app/a'b.env"), "/srv/app/a'\"'\"'b.env")
}
@(test)
test_quote_remote_shell_path_round_trips_without_injection :: proc(t: ^testing.T) {
when ODIN_OS == .Windows {
return
}
base := test_temp_dir(t, "envr-shell-quote-*")
defer os.remove_all(base)
marker, _ := filepath.join({base, "injected"}, context.temp_allocator)
quoted_marker := quote_remote_shell_path(marker)
paths := make([dynamic]string, 0, 10, context.temp_allocator)
append(&paths,
"",
"plain-name_123./:+@%-=,",
"spaces and\ttabs",
"single'quote and \"double quote\"",
"two''consecutive'''quotes",
fmt.tprintf("; touch '%s'; #", marker),
fmt.tprintf("$(touch '%s')", marker),
fmt.tprintf("`touch '%s'`", marker),
"&& || | < > ( ) $ ! * ? [ ] # ~ % { } \\",
"line one\nline two",
)
for path in paths {
if os.exists(marker) {
_ = os.remove(marker)
}
quoted_path := quote_remote_shell_path(path)
script := fmt.tprintf(
"printf '%%s' '%s'; if [ -e '%s' ]; then printf INJECTED; fi",
quoted_path,
quoted_marker,
)
desc := os.Process_Desc {
command = []string{"/bin/sh", "-c", script},
}
state, stdout, stderr, err := os.process_exec(desc, context.allocator)
delete(stdout)
delete(stderr)
testing.expectf(t, err == nil, "shell execution failed for %q: %v", path, err)
if err != nil {
return
}
testing.expectf(t, state.success, "shell failed for %q", path)
testing.expect_value(t, string(stdout), path)
testing.expectf(t, !os.exists(marker), "path %q executed the marker command", path)
}
}
@(test)
test_new_remote_env_file_owns_its_fields :: proc(t: ^testing.T) {
input_path := strings.clone("spencer@example.com:/etc/umami.env", context.allocator)
contents := make([]u8, len("SECRET=value"), context.allocator)
copy(contents, "SECRET=value")
file := new_remote_env_file(input_path, contents)
delete(input_path)
defer delete_envfile(&file)
testing.expect_value(t, file.path, "spencer@example.com:/etc/umami.env")
testing.expect_value(t, file.contents, "SECRET=value")
}
+51 -42
View File
@@ -35,18 +35,18 @@ parse_ssh_public_key :: proc(pub_path: string) -> (pub: [32]u8, ok: bool) {
return return
} }
rest := decoded offset := 0
key_type, type_ok := read_wire_string(&rest) key_type, type_ok := read_wire_string(decoded, &offset)
if !type_ok || string(key_type) != SSH_ED25519 { if !type_ok || key_type != SSH_ED25519 {
return return
} }
pk_data, pk_ok := read_wire_string(&rest) pk_data, pk_ok := read_wire_string(decoded, &offset)
if !pk_ok || len(pk_data) != 32 { if !pk_ok || len(pk_data) != 32 {
return return
} }
mem.copy_non_overlapping(&pub[0], &pk_data[0], 32) mem.copy_non_overlapping(&pub[0], raw_data(pk_data), 32)
ok = true ok = true
return return
@@ -91,63 +91,81 @@ parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: boo
return return
} }
rest := decoded[len(magic):] offset := len(magic)
ciphername, cipher_ok := read_wire_string(&rest) ciphername, cipher_ok := read_wire_string(decoded, &offset)
if !cipher_ok || string(ciphername) != "none" { if !cipher_ok || ciphername != "none" {
return return
} }
kdfname, kdf_ok := read_wire_string(&rest) kdfname, kdf_ok := read_wire_string(decoded, &offset)
if !kdf_ok || string(kdfname) != "none" { if !kdf_ok || kdfname != "none" {
return return
} }
_, opts_ok := read_wire_string(&rest) _, opts_ok := read_wire_string(decoded, &offset)
if !opts_ok { if !opts_ok {
return return
} }
num_keys, nkeys_ok := read_wire_u32(&rest) if offset + 4 > len(decoded) {
if !nkeys_ok || num_keys != 1 {
return return
} }
_, pub_blob_ok := read_wire_string(&rest) 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)
if !pub_blob_ok { if !pub_blob_ok {
return return
} }
priv_blob, priv_blob_ok := read_wire_string(&rest) priv_blob, priv_blob_ok := read_wire_string(decoded, &offset)
if !priv_blob_ok { if !priv_blob_ok {
return return
} }
inner := priv_blob inner_offset := 0
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
} }
priv_type, type_ok := read_wire_string(&inner) check1 := endian.get_u32(
if !type_ok || string(priv_type) != SSH_ED25519 { 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 {
return return
} }
pub_wire, pub_ok := read_wire_string(&inner) 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)
if !pub_ok || len(pub_wire) != 32 { if !pub_ok || len(pub_wire) != 32 {
return return
} }
mem.copy_non_overlapping(&kp.Public[0], &pub_wire[0], 32) mem.copy_non_overlapping(&kp.Public[0], raw_data(pub_wire), 32)
priv_wire, priv_ok := read_wire_string(&inner) priv_wire, priv_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
if !priv_ok || len(priv_wire) != 64 { if !priv_ok || len(priv_wire) != 64 {
return return
} }
mem.copy_non_overlapping(&kp.Private[0], &priv_wire[0], 32) mem.copy_non_overlapping(&kp.Private[0], raw_data(priv_wire), 32)
ok = true ok = true
return return
@@ -164,28 +182,19 @@ is_ed25519_key :: proc(
return ok, nil return ok, nil
} }
read_wire_string :: proc(data: ^[]u8) -> (s: []u8, ok: bool) #no_bounds_check { read_wire_string :: proc(data: []u8, offset: ^int) -> (s: string, ok: bool) {
if len(data^) < 4 { if offset^ + 4 > len(data) {
return return
} }
length := endian.get_u32(data^[:4], .Big) or_return length := endian.get_u32(data[offset^:offset^ + 4], .Big) or_return
data^ = data^[4:] offset^ += 4
if len(data^) < int(length) { if offset^ + int(length) > len(data) {
return return
} }
s = data^[:int(length)]
data^ = data^[int(length):]
ok = true
return
}
read_wire_u32 :: proc(data: ^[]u8) -> (v: u32, ok: bool) #no_bounds_check { s = string(data[offset^:offset^ + int(length)])
if len(data^) < 4 { offset^ += int(length)
return
}
v = endian.get_u32(data^[:4], .Big) or_return
data^ = data^[4:]
ok = true ok = true
return return
} }
+6 -6
View File
@@ -52,15 +52,15 @@ test_private_key_pub_matches_public_key :: proc(t: ^testing.T) {
@(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
buf := data s, ok := read_wire_string(data, &offset)
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_value(t, string(s), "hello") testing.expect_value(t, s, "hello")
testing.expect_value(t, len(data) - len(buf), 9) testing.expect_value(t, offset, 9)
s2, ok2 := read_wire_string(&buf) s2, ok2 := read_wire_string(data, &offset)
testing.expect(t, ok2, "expected second read to succeed") testing.expect(t, ok2, "expected second read to succeed")
testing.expect_value(t, len(s2), 0) testing.expect_value(t, s2, "")
} }
+16 -16
View File
@@ -1,5 +1,6 @@
package main package main
import "core:fmt"
import "core:io" import "core:io"
import "core:text/table" import "core:text/table"
@@ -17,6 +18,20 @@ decorations := table.Decorations {
"─", "─",
} }
ansi_aware_width :: proc(str: string) -> int #no_bounds_check {
width := 0
for i := 0; i < len(str); {
if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' {
i += 2
for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}}
} else {
width += 1
i += 1
}
}
return width
}
write_borderless_table :: proc(w: io.Writer, t: ^table.Table) { write_borderless_table :: proc(w: io.Writer, t: ^table.Table) {
table.build(t, ansi_aware_width) table.build(t, ansi_aware_width)
@@ -27,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,
colorize(.Heading, t.caption), fmt.tprintf("%s%s%s", COLOR_HEADINGS, t.caption, ANSI_RESET),
.Left, .Left,
0, //t.lpad, 0, //t.lpad,
0, //t.rpad, 0, //t.rpad,
@@ -49,21 +64,6 @@ write_borderless_table :: proc(w: io.Writer, t: ^table.Table) {
write_table_separator(w, t) write_table_separator(w, t)
} }
ansi_aware_width :: proc(str: string) -> int #no_bounds_check {
width := 0
for i := 0; i < len(str); {
if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' {
i += 2
for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}}
} else {
width += 1
i += 1
}
}
return width
}
table_reset :: proc(t: ^table.Table) { table_reset :: proc(t: ^table.Table) {
clear(&t.cells) clear(&t.cells)
clear(&t.colw) clear(&t.colw)
+2 -11
View File
@@ -2,7 +2,6 @@
package main package main
import "core:strings"
import "core:testing" import "core:testing"
@(test) @(test)
@@ -17,7 +16,7 @@ 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 := colorize(.Heading, "Directory", disable = false) colored := COLOR_TABLE_HEADING + "Directory" + ANSI_RESET
testing.expect_value(t, ansi_aware_width(colored), 9) testing.expect_value(t, ansi_aware_width(colored), 9)
} }
@@ -29,14 +28,6 @@ test_ansi_aware_width_multibyte :: proc(t: ^testing.T) {
@(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 := strings.concatenate( colored := COLOR_TABLE_HEADING + "a" + ANSI_RESET + "b" + COLOR_TABLE_HEADING + "c" + ANSI_RESET
{
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 @@
1.1.0 0.3.0