From b1d24161829bc975aff127291ea44f7a30c6e0ff Mon Sep 17 00:00:00 2001 From: Spencer Brower Date: Thu, 11 Jun 2026 20:40:47 -0400 Subject: [PATCH] feat(odin): ported list command. --- cli.odin | 1 + cmd_list.odin | 75 ++++++++++++ config.odin | 61 ++++++++++ db.odin | 279 +++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 1 + main.odin | 2 + sqlite/sqlite.odin | 34 ++++++ stubs.odin | 4 - 8 files changed, 453 insertions(+), 4 deletions(-) create mode 100644 cmd_list.odin create mode 100644 config.odin create mode 100644 db.odin create mode 100644 sqlite/sqlite.odin diff --git a/cli.odin b/cli.odin index 5e1647f..3736b1b 100644 --- a/cli.odin +++ b/cli.odin @@ -14,6 +14,7 @@ Command :: struct { IMPLEMENTED_COMMANDS := []string{ "version", "deps", + "list", } parse_args :: proc() -> (cmd: Command, ok: bool) { diff --git a/cmd_list.odin b/cmd_list.odin new file mode 100644 index 0000000..7bd8177 --- /dev/null +++ b/cmd_list.odin @@ -0,0 +1,75 @@ +package main + +import "core:encoding/json" +import "core:fmt" +import "core:path/filepath" +import "core:strings" + +ListEntry :: struct { + Directory: string `json:"directory"`, + Path: string `json:"path"`, +} + +cmd_list :: proc(cmd: ^Command) { + db, db_ok := db_open() + if !db_ok { + return + } + defer db_close(&db) + + rows, list_ok := db_list(&db) + if !list_ok { + return + } + defer delete(rows) + + if is_tty() { + headers := []string{"Directory", "Path"} + table_rows := make([dynamic][]string, 0, len(rows)) + + for row in rows { + b: strings.Builder + strings.builder_init(&b) + strings.write_string(&b, row.Dir) + strings.write_string(&b, "/") + dir_str, _ := strings.clone(strings.to_string(b)) + + rel, rel_err := filepath.rel(row.Dir, row.Path) + if rel_err != nil { + fmt.printf("Error getting relative path: %v\n", rel_err) + return + } + cloned_rel, _ := strings.clone(rel) + row_slice := make([]string, 2) + row_slice[0] = dir_str + row_slice[1] = cloned_rel + append(&table_rows, row_slice) + } + + render_table(headers, table_rows[:]) + } else { + entries: [dynamic]ListEntry + for row in rows { + rel, rel_err := filepath.rel(row.Dir, row.Path) + if rel_err != nil { + fmt.printf("Error getting relative path: %v\n", rel_err) + return + } + b: strings.Builder + strings.builder_init(&b) + strings.write_string(&b, row.Dir) + strings.write_string(&b, "/") + append(&entries, ListEntry{ + Directory = strings.to_string(b), + Path = rel, + }) + } + + data, marshal_err := json.marshal(entries[:]) + 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 new file mode 100644 index 0000000..ece3c68 --- /dev/null +++ b/config.odin @@ -0,0 +1,61 @@ +package main + +import "core:encoding/json" +import "core:fmt" +import "core:os" +import "core:path/filepath" + +SshKeyPair :: struct { + Private: string `json:"private"`, + Public: string `json:"public"`, +} + +ScanConfig :: struct { + Matcher: string `json:"matcher"`, + Exclude: []string `json:"exclude"`, + Include: []string `json:"include"`, +} + +Config :: struct { + Keys: []SshKeyPair `json:"keys"`, + ScanConfig: ScanConfig `json:"scan"`, +} + +load_config :: proc() -> (Config, 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 Config{}, false + } + config_path, join_err := filepath.join([]string{home, ".envr", "config.json"}) + if join_err != nil { + return Config{}, false + } + + data, read_err := os.read_entire_file_from_path(config_path, context.allocator) + if read_err != nil { + fmt.println("No config file found. Please run `envr init` to generate one.") + return Config{}, false + } + + cfg: Config + err := json.unmarshal(data, &cfg) + if err != nil { + fmt.printf("Error parsing config: %v\n", err) + return Config{}, false + } + + return cfg, true +} + +envr_dir :: proc() -> string { + home, _ := os.user_home_dir(context.allocator) + dir, _ := filepath.join([]string{home, ".envr"}) + return dir +} + +data_age_path :: proc() -> string { + dir := envr_dir() + path, _ := filepath.join([]string{dir, "data.age"}) + return path +} diff --git a/db.odin b/db.odin new file mode 100644 index 0000000..5981b18 --- /dev/null +++ b/db.odin @@ -0,0 +1,279 @@ +package main + +import "core:c" +import "core:encoding/json" +import "core:fmt" +import "core:os" +import "core:path/filepath" +import "core:strings" +import "core:time" + +import "sqlite" + +Db :: struct { + db: ^rawptr, + cfg: Config, + changed: bool, +} + +EnvFile :: struct { + Path: string, + Dir: string, + Remotes: [dynamic]string, + Sha256: string, + contents: string, +} + +make_temp_path :: proc() -> string { + ts := time.time_to_unix(time.now()) + b: strings.Builder + strings.builder_init(&b) + fmt.sbprintf(&b, "/tmp/envr-%d-%d.db", os.get_pid(), ts) + return strings.to_string(b) +} + +db_open :: proc() -> (Db, bool) { + cfg, ok := load_config() + if !ok { + return Db{}, false + } + + age_path := data_age_path() + _, stat_err := os.stat(age_path, context.allocator) + + db: ^rawptr + rc := sqlite.db_open(":memory:", &db) + if rc != sqlite.OK { + fmt.printf("Error opening in-memory database: %s\n", sqlite.db_errmsg(db)) + return Db{}, false + } + + create_sql := "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" + rc = sqlite.db_exec(db, string_to_cstring(create_sql), nil, nil, nil) + if rc != sqlite.OK { + fmt.printf("Error creating table: %s\n", sqlite.db_errmsg(db)) + sqlite.db_close(db) + return Db{}, false + } + + if stat_err == nil { + if !db_restore_from_age(db, cfg) { + sqlite.db_close(db) + return Db{}, false + } + } + + return Db{db = db, cfg = cfg, changed = stat_err != nil}, true +} + +db_close :: proc(d: ^Db) { + if d.changed { + tmp_path := make_temp_path() + + if !db_vacuum_to_file(d.db, tmp_path) { + os.remove(tmp_path) + sqlite.db_close(d.db) + return + } + + db_encrypt_file(tmp_path, d.cfg.Keys) + os.remove(tmp_path) + d.changed = false + } + sqlite.db_close(d.db) +} + +db_list :: proc(d: ^Db) -> (results: [dynamic]EnvFile, ok: bool) { + sql := "SELECT path, remotes, sha256, contents FROM envr_env_files" + stmt: ^rawptr + rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) + if rc != sqlite.OK { + fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db)) + return + } + + for { + rc = sqlite.step(stmt) + if rc == sqlite.DONE { + break + } + if rc != sqlite.ROW { + fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db)) + sqlite.finalize(stmt) + return + } + + path := cstring_to_string(sqlite.column_text(stmt, 0)) + remotes_json := cstring_to_string(sqlite.column_text(stmt, 1)) + sha := cstring_to_string(sqlite.column_text(stmt, 2)) + contents := cstring_to_string(sqlite.column_text(stmt, 3)) + + remotes: [dynamic]string + if len(remotes_json) > 0 { + json.unmarshal_string(remotes_json, &remotes) + } + + append(&results, EnvFile{ + Path = path, + Dir = filepath.dir(path), + Remotes = remotes, + Sha256 = sha, + contents = contents, + }) + } + + sqlite.finalize(stmt) + ok = true + return +} + +db_vacuum_to_file :: proc(db: ^rawptr, path: string) -> bool { + b: strings.Builder + strings.builder_init(&b) + fmt.sbprintf(&b, "VACUUM INTO '%s'", path) + sql := strings.to_string(b) + rc := sqlite.db_exec(db, string_to_cstring(sql), nil, nil, nil) + if rc != sqlite.OK { + fmt.printf("Error vacuuming database: %s\n", sqlite.db_errmsg(db)) + return false + } + return true +} + +db_restore_from_age :: proc(db: ^rawptr, cfg: Config) -> bool { + tmp_path := make_temp_path() + defer os.remove(tmp_path) + + if !db_decrypt_to_file(tmp_path, cfg.Keys) { + return false + } + + if !db_attach_and_copy(db, tmp_path) { + return false + } + + return true +} + +db_decrypt_to_file :: proc(tmp_path: string, keys: []SshKeyPair) -> bool { + age_path := data_age_path() + + args := make([dynamic]string) + append(&args, "age") + append(&args, "--decrypt") + append(&args, "-o") + append(&args, tmp_path) + for key in keys { + append(&args, "-i") + append(&args, key.Private) + } + append(&args, age_path) + + desc := os.Process_Desc{ + command = args[:], + stdout = os.stderr, + stderr = os.stderr, + } + + p, err := os.process_start(desc) + if err != nil { + fmt.printf("Error running age decrypt: %v\n", err) + return false + } + + state, wait_err := os.process_wait(p) + if wait_err != nil { + fmt.printf("Error waiting for age: %v\n", wait_err) + return false + } + if state.exit_code != 0 { + fmt.println("Error: age decryption failed") + return false + } + return true +} + +db_encrypt_file :: proc(tmp_path: string, keys: []SshKeyPair) -> bool { + age_path := data_age_path() + envr_d := envr_dir() + os.mkdir_all(envr_d) + + args := make([dynamic]string) + append(&args, "age") + append(&args, "--encrypt") + for key in keys { + append(&args, "-r") + pub_data, pub_err := os.read_entire_file_from_path(key.Public, context.allocator) + if pub_err != nil { + fmt.printf("Error reading public key: %s\n", key.Public) + return false + } + pub_str := string(pub_data) + if strings.has_suffix(pub_str, "\n") { + pub_str = pub_str[:len(pub_str)-1] + } + append(&args, pub_str) + } + append(&args, "-o") + append(&args, age_path) + append(&args, tmp_path) + + desc := os.Process_Desc{ + command = args[:], + stdout = os.stderr, + stderr = os.stderr, + } + + p, err := os.process_start(desc) + if err != nil { + fmt.printf("Error running age encrypt: %v\n", err) + return false + } + + state, wait_err := os.process_wait(p) + if wait_err != nil { + fmt.printf("Error waiting for age: %v\n", wait_err) + return false + } + if state.exit_code != 0 { + fmt.println("Error: age encryption failed") + return false + } + return true +} + +db_attach_and_copy :: proc(mem_db: ^rawptr, src_path: string) -> bool { + b: strings.Builder + strings.builder_init(&b) + fmt.sbprintf(&b, "ATTACH DATABASE '%s' AS source", src_path) + attach_sql := strings.to_string(b) + + rc := sqlite.db_exec(mem_db, string_to_cstring(attach_sql), nil, nil, nil) + if rc != sqlite.OK { + fmt.printf("Error attaching database: %s\n", sqlite.db_errmsg(mem_db)) + return false + } + + rc = sqlite.db_exec(mem_db, "INSERT INTO main.envr_env_files SELECT * FROM source.envr_env_files", nil, nil, nil) + if rc != sqlite.OK { + fmt.printf("Error copying data: %s\n", sqlite.db_errmsg(mem_db)) + sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil) + return false + } + + sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil) + return true +} + +cstring_to_string :: proc(cs: cstring) -> string { + if cs == nil { + return "" + } + s, _ := strings.clone_from_cstring(cs) + return s +} + +string_to_cstring :: proc(s: string) -> cstring { + cs, _ := strings.clone_to_cstring(s) + return cs +} diff --git a/flake.nix b/flake.nix index 82bdcbc..1194c53 100644 --- a/flake.nix +++ b/flake.nix @@ -98,6 +98,7 @@ cobra-cli age + sqlite unstable.odin unstable.ols diff --git a/main.odin b/main.odin index 480a6ef..abab14f 100644 --- a/main.odin +++ b/main.odin @@ -21,6 +21,8 @@ main :: proc() { cmd_version(&cmd) case "deps": cmd_deps(&cmd) + case "list": + cmd_list(&cmd) case: fmt.printf("Unknown command: %s\n", cmd.name) print_usage() diff --git a/sqlite/sqlite.odin b/sqlite/sqlite.odin new file mode 100644 index 0000000..9d1e463 --- /dev/null +++ b/sqlite/sqlite.odin @@ -0,0 +1,34 @@ +package sqlite + +import "core:c" + +foreign import lib "system:sqlite3" + +OK :: 0 +ROW :: 100 +DONE :: 101 + +foreign lib { + @(link_name="sqlite3_open") + db_open :: proc(filename: cstring, ppDb: ^^rawptr) -> c.int --- + @(link_name="sqlite3_close") + db_close :: proc(db: ^rawptr) -> c.int --- + @(link_name="sqlite3_errmsg") + db_errmsg :: proc(db: ^rawptr) -> cstring --- + @(link_name="sqlite3_exec") + db_exec :: proc(db: ^rawptr, sql: cstring, callback: rawptr, callback_arg: rawptr, errmsg: ^cstring) -> c.int --- + @(link_name="sqlite3_prepare_v2") + prepare_v2 :: proc(db: ^rawptr, sql: cstring, nByte: c.int, ppStmt: ^^rawptr, pzTail: ^cstring) -> c.int --- + @(link_name="sqlite3_step") + step :: proc(stmt: ^rawptr) -> c.int --- + @(link_name="sqlite3_finalize") + finalize :: proc(stmt: ^rawptr) -> c.int --- + @(link_name="sqlite3_column_text") + column_text :: proc(stmt: ^rawptr, iCol: c.int) -> cstring --- + @(link_name="sqlite3_column_bytes") + column_bytes :: proc(stmt: ^rawptr, iCol: c.int) -> c.int --- + @(link_name="sqlite3_bind_text") + bind_text :: proc(stmt: ^rawptr, idx: c.int, val: cstring, n: c.int, destructor: rawptr) -> c.int --- + @(link_name="sqlite3_changes") + changes :: proc(db: ^rawptr) -> c.int --- +} diff --git a/stubs.odin b/stubs.odin index b93bf8b..403b993 100644 --- a/stubs.odin +++ b/stubs.odin @@ -6,10 +6,6 @@ cmd_init :: proc(cmd: ^Command) { fmt.println("TODO: init") } -cmd_list :: proc(cmd: ^Command) { - fmt.println("TODO: list") -} - cmd_scan :: proc(cmd: ^Command) { fmt.println("TODO: scan") }