feat: Added support for remote files (via ssh).

This commit is contained in:
2026-10-01 18:02:16 -04:00
parent d62123e831
commit 86235145f9
5 changed files with 98 additions and 1 deletions
+5
View File
@@ -14,6 +14,11 @@
8. Add docs for "building from source"
9. Add `envr show` command that uses pager (`less`) unless -no-pager is passed.
10. Run remote SSH sync operations in parallel.
11. Envr sync should require confirmation before pushing remote files.
## Double-check AI output
+4 -1
View File
@@ -16,6 +16,10 @@ cmd_restore :: proc(cmd: ^Command) {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return
}
if _, _, is_remote := parse_remote_path(path); is_remote {
fmt.wprintln(cmd.err, "Remote restore is not supported yet", flush = false)
return
}
abs_path, abs_err := filepath.abs(path, context.temp_allocator)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
@@ -50,4 +54,3 @@ cmd_restore :: proc(cmd: ^Command) {
fmt.wprintf(cmd.out, "Restored %s\n", file.path, flush = false)
}
+42
View File
@@ -399,6 +399,14 @@ db_delete :: proc(db: ^Db, path: string) -> bool {
// Caller is responsible for the returned memory
new_env_file :: proc(path: string) -> (EnvFile, bool) {
if host, remote_path, is_remote := parse_remote_path(path); is_remote {
contents, read_ok := read_remote_file(host, remote_path)
if !read_ok {
return EnvFile{}, false
}
return new_remote_env_file(path, contents), true
}
abs_path, abs_err := filepath.abs(path)
if abs_err != nil {
fmt.eprintf("Error getting absolute path: %v\n", abs_err)
@@ -428,12 +436,46 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
true
}
// Takes ownership of contents and returns an EnvFile that owns all its fields.
new_remote_env_file :: proc(path: string, contents: []byte) -> EnvFile {
digest := hash.hash_bytes(hash.Algorithm.SHA256, contents, context.temp_allocator)
hex_bytes := hex.encode(digest, context.allocator)
return EnvFile {
path = strings.clone(path, context.allocator),
dir = "",
sha256 = string(hex_bytes),
contents = string(contents),
}
}
// Reconciles `f` with the filesystem and persists changes to the database.
db_sync :: proc(db: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) {
allocator := db_allocator(db)
result: SyncFlag = {}
old_path := f.path
if host, remote_path, is_remote := parse_remote_path(f.path); is_remote {
contents, read_ok := read_remote_file(host, remote_path)
if !read_ok {
return {}, .ReadFailed
}
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 {
return {}, .None
}
f.contents = string(contents)
f.sha256 = current_sha
if !db_persist(db, f, old_path) {
return {}, .DbFailed
}
return {.BackedUp}, .None
}
if !os.exists(f.dir) {
moved, err := try_move_dir(db, f, allocator)
if !moved {
+6
View File
@@ -72,10 +72,12 @@
nativeBuildInputs = [
pkgs.unstable.odin
pkgs.pkg-config
pkgs.makeWrapper
];
buildInputs = [
pkgs.git
pkgs.openssh
pkgs.libsodium
mysqlite
@@ -108,6 +110,10 @@
cp docs/man/*.1 $out/share/man/man1/
runHook postInstall
'';
postFixup = ''
wrapProgram $out/bin/envr --prefix PATH : ${nixpkgs.lib.makeBinPath [ pkgs.openssh ]}
'';
};
devShells.default = pkgs.mkShell {
+41
View File
@@ -0,0 +1,41 @@
#+test
package main
import "core:strings"
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:relative.env")
testing.expect(t, !ok, "remote path must be absolute")
}
@(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_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")
}