refactor(odin): Ported init command.

This commit is contained in:
2026-06-12 09:16:28 -04:00
parent 191ba305ef
commit 5865315161
8 changed files with 441 additions and 11 deletions

View File

@@ -51,7 +51,7 @@ func NewConfig(privateKeyPaths []string) Config {
Matcher: "\\.env",
Exclude: []string{
"*\\.envrc",
"\\.local/",
"\\.local",
"node_modules",
"vendor",
},

View File

@@ -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) {

53
cmd_init.odin Normal file
View File

@@ -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),
)
}

95
cmd_sync.odin Normal file
View File

@@ -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))
}
}

View File

@@ -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
}

151
db.odin
View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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")
}