From 586531516198eb53d4f0569a7760d9d29660f2e8 Mon Sep 17 00:00:00 2001 From: Spencer Brower Date: Fri, 12 Jun 2026 09:16:28 -0400 Subject: [PATCH] refactor(odin): Ported init command. --- app/config.go | 2 +- cli.odin | 2 + cmd_init.odin | 53 ++++++++++++++++++ cmd_sync.odin | 95 +++++++++++++++++++++++++++++++ config.odin | 135 ++++++++++++++++++++++++++++++++++++++++++++ db.odin | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++ main.odin | 4 ++ stubs.odin | 10 ---- 8 files changed, 441 insertions(+), 11 deletions(-) create mode 100644 cmd_init.odin create mode 100644 cmd_sync.odin diff --git a/app/config.go b/app/config.go index ce890d5..7787aa6 100644 --- a/app/config.go +++ b/app/config.go @@ -51,7 +51,7 @@ func NewConfig(privateKeyPaths []string) Config { Matcher: "\\.env", Exclude: []string{ "*\\.envrc", - "\\.local/", + "\\.local", "node_modules", "vendor", }, diff --git a/cli.odin b/cli.odin index cdde843..ae268d5 100644 --- a/cli.odin +++ b/cli.odin @@ -36,6 +36,7 @@ COMMANDS := []CommandInfo{ } IMPLEMENTED_COMMANDS := []string{ + "init", "version", "deps", "list", @@ -46,6 +47,7 @@ IMPLEMENTED_COMMANDS := []string{ "edit-config", "check", "scan", + "sync", } parse_args :: proc() -> (cmd: Command, ok: bool) { diff --git a/cmd_init.odin b/cmd_init.odin new file mode 100644 index 0000000..be2f13a --- /dev/null +++ b/cmd_init.odin @@ -0,0 +1,53 @@ +package main + +import "core:fmt" + +cmd_init :: proc(cmd: ^Command) { + force := has_flag(cmd, "force") || has_flag(cmd, "f") + + _, cfg_exists := load_config() + if cfg_exists && !force { + fmt.println("You have already initialized envr.") + fmt.println("Run again with the --force flag if you want to reinitialize.") + return + } + + keys, ok := find_ssh_private_keys() + if !ok { + return + } + + if len(keys) == 0 { + fmt.println("No SSH private keys found in ~/.ssh") + return + } + + selected, result := multi_select("Select SSH private keys:", keys[:]) + if result == .Cancel { + fmt.println("\x1b[2mCancelled.\x1b[0m") + return + } + + selected_paths := make([dynamic]string, 0, min(1, len(keys) / 2)) + for i in 0 ..< len(keys) { + if selected[i] { + append(&selected_paths, keys[i]) + } + } + + if len(selected_paths) == 0 { + fmt.println("No SSH keys selected - Config not created") + return + } + + cfg := new_config(selected_paths[:]) + if !save_config(cfg, force = force) { + return + } + + fmt.printf( + "Config initialized with %d SSH key(s). You are ready to use envr.\n", + len(selected_paths), + ) +} + diff --git a/cmd_sync.odin b/cmd_sync.odin new file mode 100644 index 0000000..639dfea --- /dev/null +++ b/cmd_sync.odin @@ -0,0 +1,95 @@ +package main + +import "core:encoding/json" +import "core:fmt" +import "core:strings" + +SyncEntry :: struct { + Path: string `json:"path"`, + Status: string `json:"status"`, +} + +cmd_sync :: proc(cmd: ^Command) { + db, db_ok := db_open() + if !db_ok { + return + } + defer db_close(&db) + + files, list_ok := db_list(&db) + if !list_ok { + return + } + defer delete(files) + + results: [dynamic]SyncEntry + + for &file in files { + old_path: string + old_path, _ = strings.clone(file.Path) + + result, err_msg := db_sync(&db, &file) + + status: string + s := i32(result) + is_error := (s & i32(SyncResult.Error)) != 0 + is_backed := (s & i32(SyncResult.BackedUp)) != 0 + is_restored := (s & i32(SyncResult.Restored)) != 0 + is_dir_updated := (s & i32(SyncResult.DirUpdated)) != 0 + + if is_error { + if len(err_msg) > 0 { + status = err_msg + } else { + status = "error" + } + } else if is_backed { + status = "Backed Up" + if !db_insert(&db, file) { + return + } + } else if is_restored { + status = "Restored" + } else if is_dir_updated && !is_restored { + status = "Moved" + } else { + status = "OK" + } + + if is_dir_updated { + if !db_delete(&db, old_path) { + return + } + } + if db_update_required(result) { + if !db_insert(&db, file) { + return + } + } + + path_str, _ := strings.clone(file.Path) + status_str, _ := strings.clone(status) + append(&results, SyncEntry{Path = path_str, Status = status_str}) + } + + if is_tty() { + headers := []string{"File", "Status"} + table_rows := make([dynamic][]string, 0, len(results)) + + for res in results { + row_slice := make([]string, 2) + row_slice[0] = res.Path + row_slice[1] = res.Status + append(&table_rows, row_slice) + } + + render_table(headers, table_rows[:]) + } else { + data, marshal_err := json.marshal(results[:]) + if marshal_err != nil { + fmt.printf("Error marshaling JSON: %v\n", marshal_err) + return + } + fmt.println(string(data)) + } +} diff --git a/config.odin b/config.odin index 53933f3..f41668a 100644 --- a/config.odin +++ b/config.odin @@ -61,6 +61,119 @@ data_age_path :: proc() -> string { return path } +find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) { + home, home_err := os.user_home_dir(context.allocator) + if home_err != nil { + fmt.printf("Error getting home dir: %v\n", home_err) + return + } + + ssh_dir, join_err := filepath.join([]string{home, ".ssh"}) + if join_err != nil { + fmt.printf("Error building ssh path: %v\n", join_err) + return + } + + entries, dir_err := os.read_all_directory_by_path(ssh_dir, context.allocator) + if dir_err != nil { + fmt.printf("Could not read ~/.ssh directory: %v\n", dir_err) + return + } + defer os.file_info_slice_delete(entries, context.allocator) + + for entry in entries { + name := entry.name + if entry.type == .Directory { + continue + } + if strings.has_suffix(name, ".pub") { + continue + } + if strings.contains(name, "known_hosts") { + continue + } + if strings.contains(name, "config") { + continue + } + + full_path, _ := filepath.join([]string{ssh_dir, name}) + append(&keys, full_path) + } + + ok = true + return +} + +new_config :: proc(private_key_paths: []string) -> Config { + keys := make([dynamic]SshKeyPair, 0, len(private_key_paths)) + for priv in private_key_paths { + pub, _ := strings.concatenate([]string{priv, ".pub"}) + append(&keys, SshKeyPair{Private = priv, Public = pub}) + } + + exclude := make([dynamic]string, 0, 4) + append(&exclude, "*\\.envrc") + append(&exclude, "\\.local") + append(&exclude, "node_modules") + append(&exclude, "vendor") + + include := make([dynamic]string, 0, 1) + append(&include, "~") + + scan_cfg := ScanConfig { + Matcher = "\\.env", + Exclude = exclude[:], + Include = include[:], + } + + return Config{Keys = keys[:], ScanConfig = scan_cfg} +} + +save_config :: proc(cfg: Config, force: bool = false) -> bool { + home, home_err := os.user_home_dir(context.allocator) + if home_err != nil { + fmt.printf("Error getting home dir: %v\n", home_err) + return false + } + + config_dir, _ := filepath.join([]string{home, ".envr"}) + + if !os.exists(config_dir) { + mkdir_err := os.make_directory(config_dir) + if mkdir_err != nil { + fmt.printf("Error creating ~/.envr directory: %v\n", mkdir_err) + return false + } + } + + config_path, _ := filepath.join([]string{config_dir, "config.json"}) + + if !force && os.exists(config_path) { + info, stat_err := os.stat(config_path, context.allocator) + if stat_err == nil { + defer os.file_info_delete(info, context.allocator) + if info.size > 0 { + fmt.println("Config file already exists. Run again with --force to reinitialize.") + return false + } + } + } + + data, marshal_err := json.marshal(cfg, {pretty = true, use_spaces = true, spaces = 2}) + if marshal_err != nil { + fmt.printf("Error marshaling config: %v\n", marshal_err) + return false + } + + write_err := os.write_entire_file(config_path, data) + if write_err != nil { + fmt.printf("Error writing config: %v\n", write_err) + return false + } + + return true +} + search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) { home, _ := os.user_home_dir(context.allocator) @@ -79,3 +192,25 @@ search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) { return } +find_git_roots :: proc(cfg: Config) -> (roots: [dynamic]string, ok: bool) { + paths := search_paths(cfg) + + for sp in paths { + args := []string{"fd", "-H", "-t", "d", "^\\.git$", sp} + lines, fd_ok := run_fd(args) + if !fd_ok { + return + } + + for line in lines { + cleaned, _ := filepath.clean(line) + parent := filepath.dir(cleaned) + cloned, _ := strings.clone(parent) + append(&roots, cloned) + } + } + + ok = true + return +} + diff --git a/db.odin b/db.odin index 8b44c51..c5fd5d5 100644 --- a/db.odin +++ b/db.odin @@ -12,6 +12,19 @@ import "core:time" import "sqlite" +SyncResult :: enum i32 { + Noop = 0, + DirUpdated = 1, + Restored = 1 << 1, + BackedUp = 1 << 2, + Error = 1 << 3, +} + +SyncDirection :: enum { + TrustDatabase, + TrustFilesystem, +} + Db :: struct { db: ^rawptr, cfg: Config, @@ -470,3 +483,141 @@ string_to_cstring :: proc(s: string) -> cstring { cs, _ := strings.clone_to_cstring(s) return cs } + +db_update_required :: proc(status: SyncResult) -> bool { + s := i32(status) + return (s & (i32(SyncResult.BackedUp) | i32(SyncResult.DirUpdated))) != 0 +} + +shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool { + remote_set: map[string]bool + for r in f.Remotes { + remote_set[r] = true + } + for r in remotes { + if r in remote_set { + return true + } + } + return false +} + +update_dir :: proc(f: ^EnvFile, new_dir: string) { + f.Dir = new_dir + base := filepath.base(f.Path) + new_path, _ := strings.concatenate({new_dir, "/", base}) + f.Path = new_path + f.Remotes = get_git_remotes(new_dir) +} + +find_moved_dirs :: proc(d: ^Db, f: ^EnvFile) -> ([dynamic]string, bool) { + feats := check_features() + if !has_feature(feats, .Fd) || !has_feature(feats, .Git) { + fmt.println("Error: fd and git are required for moved dir detection") + return {}, false + } + + roots, roots_ok := find_git_roots(d.cfg) + if !roots_ok { + return {}, false + } + + moved: [dynamic]string + for root in roots { + remotes := get_git_remotes(root) + if shares_remote(f, remotes[:]) { + cloned, _ := strings.clone(root) + append(&moved, cloned) + } + } + return moved, true +} + +env_file_backup :: proc(f: ^EnvFile) -> bool { + data, read_err := os.read_entire_file_from_path(f.Path, context.allocator) + if read_err != nil { + fmt.printf("Error reading file %s: %v\n", f.Path, read_err) + return false + } + + f.contents = string(data) + digest := hash.hash_bytes(hash.Algorithm.SHA256, data) + hex_bytes, _ := hex.encode(digest) + f.Sha256 = string(hex_bytes) + return true +} + +env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncResult, string) { + result: SyncResult = .Noop + err_msg: string + + _, stat_err := os.stat(f.Dir, context.allocator) + if stat_err != nil { + moved_dirs: [dynamic]string + + if d != nil { + dirs, dirs_ok := find_moved_dirs(d, f) + if !dirs_ok { + return .Error, "failed to find moved dirs" + } + moved_dirs = dirs + } + + if len(moved_dirs) == 0 { + return .Error, "directory missing" + } else if len(moved_dirs) == 1 { + update_dir(f, moved_dirs[0]) + result = .DirUpdated + } else { + return .Error, "multiple directories found" + } + } + + _, file_stat_err := os.stat(f.Path, context.allocator) + if file_stat_err != nil { + write_err := os.write_entire_file(f.Path, f.contents) + if write_err != nil { + msg, _ := strings.concatenate({"failed to write file: ", fmt.aprintf("%v", write_err)}) + return .Error, msg + } + + s := i32(result) | i32(SyncResult.Restored) + return SyncResult(s), "" + } + + data, read_err := os.read_entire_file_from_path(f.Path, context.allocator) + if read_err != nil { + msg, _ := strings.concatenate({"failed to read file for SHA comparison: ", fmt.aprintf("%v", read_err)}) + return .Error, msg + } + + digest := hash.hash_bytes(hash.Algorithm.SHA256, data) + hex_bytes, _ := hex.encode(digest) + current_sha := string(hex_bytes) + + if current_sha == f.Sha256 { + return result, "" + } + + switch dir { + case .TrustDatabase: + write_err := os.write_entire_file(f.Path, f.contents) + if write_err != nil { + msg, _ := strings.concatenate({"failed to write file: ", fmt.aprintf("%v", write_err)}) + return .Error, msg + } + s := i32(result) | i32(SyncResult.Restored) + return SyncResult(s), "" + case .TrustFilesystem: + if !env_file_backup(f) { + return .Error, "failed to backup file" + } + return .BackedUp, "" + } + + return result, "" +} + +db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncResult, string) { + return env_file_sync(f, .TrustFilesystem, d) +} diff --git a/main.odin b/main.odin index 233c9dd..4818285 100644 --- a/main.odin +++ b/main.odin @@ -17,6 +17,8 @@ main :: proc() { } switch cmd.name { + case "init": + cmd_init(&cmd) case "version": cmd_version(&cmd) case "deps": @@ -35,6 +37,8 @@ main :: proc() { cmd_check(&cmd) case "scan": cmd_scan(&cmd) + case "sync": + cmd_sync(&cmd) case: fmt.printf("Unknown command: %s\n", cmd.name) print_usage() diff --git a/stubs.odin b/stubs.odin index 1c0034b..06ab7d0 100644 --- a/stubs.odin +++ b/stubs.odin @@ -1,11 +1 @@ package main - -import "core:fmt" - -cmd_init :: proc(cmd: ^Command) { - fmt.println("TODO: init") -} - -cmd_sync :: proc(cmd: ^Command) { - fmt.println("TODO: sync") -}