/****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2026, Leafscale, LLC - https://www.leafscale.com Project: repoman Filename: src/cli.reef Authors: Chris Tusa License: Description: Subcommand implementations (cmd_new, cmd_sync, cmd_list, cmd_remove, cmd_shell, cmd_status, cmd_profile) ******************************************************************************/ module cli import core.str import core.result_generic as rg import core.convert as convert import time.time as time import io.console as console import io.file as iofile import sys.flag as flag import sys.env as env import sys.args as args import sys.process as p import config import incus import profile import setup import sync import paths import log export fn cmd_new(argv: [string]): int fn cmd_profile(argv: [string]): int fn cmd_setup(argv: [string]): int fn cmd_sync(argv: [string]): int fn cmd_list(argv: [string]): int fn cmd_status(argv: [string]): int fn cmd_remove(argv: [string]): int fn cmd_shell(argv: [string]): int fn dispatch(argv: [string]): int end export // argv passed in is the slice past argv[1] (i.e., excludes program + subcommand). fn cmd_new(argv: [string]): int let parser: flag.FlagParser = flag.flag_parser_from(argv) flag.application(parser, "repoman new") flag.description(parser, "Create a new container + repo bind") let _r1 = flag.string_flag(parser, "repo", '\0', "", "repo dirname (defaults to )") let _r2 = flag.string_flag(parser, "image", '\0', "", "container image (overrides default)") let _v = flag.bool_flag(parser, "verbose", 'v', false, "show subprocess output (incus probes)") let _q = flag.bool_flag(parser, "quiet", 'q', false, "force quiet mode even if config sets verbose") if not flag.parse(parser) console.printErr("repoman: error: " + flag.error(parser)) return 2 end if let positionals: [string] = flag.positional_args(parser) if positionals.length() != 1 console.printErr("repoman: error: 'new' takes exactly one positional argument: ") return 2 end if let name: string = positionals[0] let repo_flag: string = flag.get_string(parser, "repo") let image_flag: string = flag.get_string(parser, "image") if not incus.validate_name(name) console.printErr("repoman: error: invalid container name: " + name) console.printErr("hint: lowercase alphanumeric + hyphens, <=63 chars, no leading hyphen") return 1 end if let home: string = env.get_env_or("HOME", "") if str.length(home) == 0 console.printErr("repoman: error: HOME is not set") return 3 end if let cfg_path: string = config.registry_path(home) let reg_r = config.load_or_init(home) if rg.is_err(reg_r) console.printErr("repoman: error: " + rg.unwrap_err(reg_r)) return 3 end if let reg: config.Registry = rg.unwrap_ok(reg_r) // Open per-invocation log file. Failures degrade to stderr-only with a // warning; logging is best-effort, never blocks the operation. let _ol: bool = log.open_log(reg.defaults.logdir, name, "new") // Resolve verbose mode: --quiet wins, then --verbose, then registry default. let cli_verbose: bool = flag.get_bool(parser, "verbose") let cli_quiet: bool = flag.get_bool(parser, "quiet") mut verbose: bool = reg.output == "verbose" if cli_verbose verbose = true end if if cli_quiet verbose = false end if // Reject duplicate name let pn: int = reg.projects.length() mut i: int = 0 while i < pn if reg.projects[i].name == name log.write("repoman: error: project '" + name + "' already in registry") log.write("hint: incus delete --project " + reg.defaults.incus_project + " " + name + " ; then remove from " + cfg_path) return 4 end if i = i + 1 end while // Resolve repo path mut repo: string = repo_flag if str.length(repo) == 0 repo = name end if let repos_root: string = paths.expand_home(reg.defaults.repos_root) let repo_path: string = paths.join(repos_root, repo) if not paths.is_dir(repo_path) log.write("repoman: error: no repo at " + repo_path) return 3 end if // Read override (optional) let override_path: string = paths.join(home, ".config/repoman/repos.d/" + name + ".toml") mut override: config.Override = config.Override { image: "", profiles: new [string](0), has_profiles: false, mounts: new [config.Mount](0), env_keys: new [string](0), env_values: new [string](0) } if iofile.fileExists(override_path) let ov_r = config.parse_override(iofile.readFile(override_path)) if rg.is_err(ov_r) log.write("repoman: error: bad override " + override_path + ": " + rg.unwrap_err(ov_r)) return 3 end if override = rg.unwrap_ok(ov_r) end if let eff: config.EffectiveConfig = config.merge_with_defaults(name, repo, image_flag, override, reg.defaults) // Pre-launch profile validation: every name in eff.profiles must either be // the magic incus 'default' profile, or installed in the 'default' project. // (Repoman-managed profiles all live in 'default' per the v0.4 architecture.) let pn2: int = eff.profiles.length() mut pi: int = 0 while pi < pn2 let pname: string = eff.profiles[pi] if pname != "default" let exists_r = incus.profile_exists("default", pname) if rg.is_ok(exists_r) and not rg.unwrap_ok(exists_r) log.write("repoman: error: container references profile '" + pname + "' but it's not installed in incus.") log.write("hint: repoman profile install " + pname) log.write("hint: repoman profile install --all (to install the vendor library)") return 4 end if end if pi = pi + 1 end while // Ensure incus project log.write("==> incus project ensure " + reg.defaults.incus_project) let pe = incus.project_ensure(reg.defaults.incus_project, verbose) if rg.is_err(pe) log.write("repoman: error: " + rg.unwrap_err(pe)) return 1 end if // Reject if container exists already let ce = incus.container_exists(reg.defaults.incus_project, name, verbose) if rg.is_err(ce) log.write("repoman: error: " + rg.unwrap_err(ce)) return 1 end if if rg.unwrap_ok(ce) log.write("repoman: error: container '" + name + "' already exists in project '" + reg.defaults.incus_project + "'") log.write("hint: incus delete --project " + reg.defaults.incus_project + " " + name) return 4 end if // Launch log.write("==> incus launch " + eff.image + " " + name) let lr = incus.launch(reg.defaults.incus_project, name, eff.image, eff.profiles) if rg.is_err(lr) log.write("repoman: error: " + rg.unwrap_err(lr)) return 1 end if // Mounts: device names "repo" for the auto bind, "mount-1", "mount-2", ... let mn: int = eff.mounts.length() mut k: int = 0 while k < mn let m: config.Mount = eff.mounts[k] mut dev_name: string = "repo" if k > 0 dev_name = "mount-" + convert.to_string(k) end if log.write("==> incus device add " + name + " " + dev_name + " " + m.source + ":" + m.path + " shift=true") let dr = incus.device_add_disk_opts(reg.defaults.incus_project, name, dev_name, m.source, m.path, ["shift=true"]) if rg.is_err(dr) log.write("repoman: error: " + rg.unwrap_err(dr)) log.write("hint: incus delete --project " + reg.defaults.incus_project + " " + name) return 1 end if k = k + 1 end while // Env let en: int = eff.env_keys.length() mut e: int = 0 while e < en let er = incus.set_env_var(reg.defaults.incus_project, name, eff.env_keys[e], eff.env_values[e]) if rg.is_err(er) log.write("repoman: error: " + rg.unwrap_err(er)) return 1 end if e = e + 1 end while // Restart so binds + env take effect log.write("==> incus restart " + name) let rr = incus.restart(reg.defaults.incus_project, name) if rg.is_err(rr) log.write("repoman: error: " + rg.unwrap_err(rr)) return 1 end if // Build new project entry and write registry let now: string = time.time_format_iso(time.time_now()) let new_p: config.Project = config.Project { name: name, repo: repo, image: eff.image, profiles: eff.profiles, created: now, last_sync: "", backup: true } let reg2_r = config.add_project(reg, new_p) if rg.is_err(reg2_r) log.write("repoman: error: " + rg.unwrap_err(reg2_r)) return 1 end if let saved = config.save(rg.unwrap_ok(reg2_r), cfg_path) if rg.is_err(saved) log.write("repoman: error: " + rg.unwrap_err(saved)) return 1 end if // Ready hint — recommend the repoman subcommands now that they exist. log.write("==> ready") log.write("") log.write(" shell in: repoman shell " + name) log.write(" run claude: incus exec --project " + reg.defaults.incus_project + " " + name + " -- claude") return 0 end cmd_new fn cmd_sync(argv: [string]): int let parser: flag.FlagParser = flag.flag_parser_from(argv) flag.application(parser, "repoman sync") flag.description(parser, "rsync local repos → NFS backup") let _f1 = flag.bool_flag(parser, "no-delete", '\0', false, "additive only — no deletions on the destination") let _f2 = flag.bool_flag(parser, "dry-run", '\0', false, "preview changes without writing") let _v = flag.bool_flag(parser, "verbose", 'v', false, "show subprocess output (NFS probes)") let _q = flag.bool_flag(parser, "quiet", 'q', false, "force quiet mode even if config sets verbose") if not flag.parse(parser) console.printErr("repoman: error: " + flag.error(parser)) return 2 end if let positionals: [string] = flag.positional_args(parser) if positionals.length() > 1 console.printErr("repoman: error: 'sync' takes at most one positional argument: [name]") return 2 end if let no_delete: bool = flag.get_bool(parser, "no-delete") let dry_run: bool = flag.get_bool(parser, "dry-run") let home: string = env.get_env_or("HOME", "") if str.length(home) == 0 console.printErr("repoman: error: HOME is not set") return 3 end if let reg_r = config.load_or_init(home) if rg.is_err(reg_r) console.printErr("repoman: error: " + rg.unwrap_err(reg_r)) return 3 end if let reg: config.Registry = rg.unwrap_ok(reg_r) let cfg_path: string = config.registry_path(home) // Open per-invocation log file. Single-project sync uses the project // name; whole-tree sync uses "all". mut log_label: string = "all" if positionals.length() == 1 log_label = positionals[0] end if let _ol: bool = log.open_log(reg.defaults.logdir, log_label, "sync") // Resolve verbose mode: --quiet wins, then --verbose, then registry default. let cli_verbose: bool = flag.get_bool(parser, "verbose") let cli_quiet: bool = flag.get_bool(parser, "quiet") mut verbose: bool = reg.output == "verbose" if cli_verbose verbose = true end if if cli_quiet verbose = false end if let backup_root: string = paths.expand_home(reg.defaults.backup_root) let repos_root: string = paths.expand_home(reg.defaults.repos_root) // ensure_nfs_mounted let mr = sync.ensure_nfs_mounted(backup_root, verbose) if rg.is_err(mr) log.write("repoman: error: " + rg.unwrap_err(mr)) return 3 end if // Resolve target mut src: string = "" mut dst: string = "" mut excluded: [string] = new [string](0) mut single_target: string = "" if positionals.length() == 1 let name: string = positionals[0] // Find in registry let pn: int = reg.projects.length() mut found: int = -1 mut i: int = 0 while i < pn if reg.projects[i].name == name found = i end if i = i + 1 end while if found < 0 log.write("repoman: error: '" + name + "' not in registry") log.write("hint: repoman new " + name) return 1 end if let proj: config.Project = reg.projects[found] if not proj.backup log.write("repoman: error: '" + name + "' has backup = false; refusing single-target sync") return 1 end if src = paths.join(repos_root, proj.repo) + "/" dst = paths.join(backup_root, proj.repo) + "/" single_target = name else // whole tree src = repos_root + "/" dst = backup_root + "/" // Build excludes for backup=false projects let pn: int = reg.projects.length() mut buf: [string] = new [string](pn) mut count: int = 0 mut i: int = 0 while i < pn if not reg.projects[i].backup buf[count] = reg.projects[i].repo count = count + 1 end if i = i + 1 end while mut tight: [string] = new [string](count) mut j: int = 0 while j < count tight[j] = buf[j] j = j + 1 end while excluded = tight end if // Build args + log + run let is_tty: bool = false // v0.1: assume non-TTY (cron-friendly defaults). let rsync_args: [string] = sync.build_rsync_args(src, dst, dry_run, no_delete, is_tty, excluded) mut tags: string = "" if dry_run tags = tags + "(dry-run) " end if if no_delete tags = tags + "(additive) " end if log.write("==> rsync " + tags + src + " → " + dst) let exit_code: int = sync.run_rsync(rsync_args) if exit_code < 0 log.write("repoman: error: failed to spawn rsync") return 1 end if if exit_code != 0 return exit_code end if // Success: update last_sync. Skip in dry-run mode (nothing changed). if not dry_run let now: string = time.time_format_iso(time.time_now()) if str.length(single_target) > 0 let upd = config.update_last_sync(reg, single_target, now) if rg.is_ok(upd) let _s1 = config.save(rg.unwrap_ok(upd), cfg_path) end if else mut cur: config.Registry = reg let pn: int = cur.projects.length() mut i: int = 0 while i < pn if cur.projects[i].backup let upd = config.update_last_sync(cur, cur.projects[i].name, now) if rg.is_ok(upd) cur = rg.unwrap_ok(upd) end if end if i = i + 1 end while let _s2 = config.save(cur, cfg_path) end if end if return 0 end cmd_sync fn cmd_list(argv: [string]): int let parser: flag.FlagParser = flag.flag_parser_from(argv) flag.application(parser, "repoman list") flag.description(parser, "List registered projects") if not flag.parse(parser) console.printErr("repoman: error: " + flag.error(parser)) return 2 end if let positionals: [string] = flag.positional_args(parser) if positionals.length() > 0 console.printErr("repoman: error: 'list' takes no positional arguments") return 2 end if let home: string = env.get_env_or("HOME", "") if str.length(home) == 0 console.printErr("repoman: error: HOME is not set") return 3 end if let reg_r = config.load_or_init(home) if rg.is_err(reg_r) console.printErr("repoman: error: " + rg.unwrap_err(reg_r)) return 1 end if let reg: config.Registry = rg.unwrap_ok(reg_r) let n: int = reg.projects.length() if n == 0 println("no projects registered") return 0 end if // Header println( str.pad_right("NAME", 12, ' ') + str.pad_right("REPO", 18, ' ') + str.pad_right("IMAGE", 33, ' ') + str.pad_right("CREATED", 21, ' ') + str.pad_right("LAST_SYNC", 21, ' ') + "BACKUP" ) mut i: int = 0 while i < n let p: config.Project = reg.projects[i] mut backup_str: string = "no" if p.backup backup_str = "yes" end if println( str.pad_right(p.name, 12, ' ') + str.pad_right(p.repo, 18, ' ') + str.pad_right(p.image, 33, ' ') + str.pad_right(p.created, 21, ' ') + str.pad_right(p.last_sync, 21, ' ') + backup_str ) i = i + 1 end while return 0 end cmd_list fn cmd_status(argv: [string]): int let parser: flag.FlagParser = flag.flag_parser_from(argv) flag.application(parser, "repoman status") flag.description(parser, "Show registered projects' state") if not flag.parse(parser) console.printErr("repoman: error: " + flag.error(parser)) return 2 end if let positionals: [string] = flag.positional_args(parser) if positionals.length() > 1 console.printErr("repoman: error: 'status' takes at most one positional argument: [name]") return 2 end if let home: string = env.get_env_or("HOME", "") if str.length(home) == 0 console.printErr("repoman: error: HOME is not set") return 3 end if let reg_r = config.load_or_init(home) if rg.is_err(reg_r) console.printErr("repoman: error: " + rg.unwrap_err(reg_r)) return 1 end if let reg: config.Registry = rg.unwrap_ok(reg_r) if positionals.length() == 1 return cmd_status_one(reg, positionals[0]) end if return cmd_status_all(reg) end cmd_status // Detailed view for a single project. fn cmd_status_one(reg: config.Registry, name: string): int let pn: int = reg.projects.length() mut found: int = -1 mut i: int = 0 while i < pn if reg.projects[i].name == name found = i end if i = i + 1 end while if found < 0 console.printErr("repoman: error: '" + name + "' not in registry") return 1 end if let proj: config.Project = reg.projects[found] let state_r = incus.container_state(reg.defaults.incus_project, name) mut state: string = "?" if rg.is_ok(state_r) state = rg.unwrap_ok(state_r) end if println("name: " + proj.name) println("repo: " + proj.repo) println("image: " + proj.image) println("created: " + proj.created) println("last_sync: " + proj.last_sync) mut backup_str: string = "no" if proj.backup backup_str = "yes" end if println("backup: " + backup_str) println("state: " + state) return 0 end cmd_status_one // Whole-tree table. fn cmd_status_all(reg: config.Registry): int let n: int = reg.projects.length() if n == 0 println("no projects registered") return 0 end if println( str.pad_right("NAME", 12, ' ') + str.pad_right("STATE", 10, ' ') + str.pad_right("CREATED", 21, ' ') + str.pad_right("LAST_SYNC", 21, ' ') + "BACKUP" ) mut i: int = 0 while i < n let p: config.Project = reg.projects[i] let state_r = incus.container_state(reg.defaults.incus_project, p.name) mut state: string = "?" if rg.is_ok(state_r) state = rg.unwrap_ok(state_r) end if mut backup_str: string = "no" if p.backup backup_str = "yes" end if println( str.pad_right(p.name, 12, ' ') + str.pad_right(state, 10, ' ') + str.pad_right(p.created, 21, ' ') + str.pad_right(p.last_sync, 21, ' ') + backup_str ) i = i + 1 end while return 0 end cmd_status_all fn cmd_remove(argv: [string]): int let parser: flag.FlagParser = flag.flag_parser_from(argv) flag.application(parser, "repoman remove") flag.description(parser, "Remove a project: delete its container and registry entry") let _y = flag.bool_flag(parser, "yes", 'y', false, "skip the confirmation prompt") let _k = flag.bool_flag(parser, "keep-incus", '\0', false, "leave the incus container; only remove from registry") if not flag.parse(parser) console.printErr("repoman: error: " + flag.error(parser)) return 2 end if let positionals: [string] = flag.positional_args(parser) if positionals.length() != 1 console.printErr("repoman: error: 'remove' takes exactly one positional argument: ") return 2 end if let name: string = positionals[0] let keep_incus: bool = flag.get_bool(parser, "keep-incus") let home: string = env.get_env_or("HOME", "") if str.length(home) == 0 console.printErr("repoman: error: HOME is not set") return 3 end if let cfg_path: string = config.registry_path(home) let reg_r = config.load_or_init(home) if rg.is_err(reg_r) console.printErr("repoman: error: " + rg.unwrap_err(reg_r)) return 3 end if let reg: config.Registry = rg.unwrap_ok(reg_r) // Confirm name is actually in registry before prompting or doing anything destructive. let pn: int = reg.projects.length() mut found: int = -1 mut i: int = 0 while i < pn if reg.projects[i].name == name found = i end if i = i + 1 end while if found < 0 console.printErr("repoman: error: '" + name + "' not in registry") return 1 end if // Confirmation: --yes skips the prompt; otherwise spell out exactly what // gets removed so the user isn't left wondering whether their source is at risk. let auto_confirmed: bool = flag.get_bool(parser, "yes") if not auto_confirmed let repo_path: string = reg.projects[found].repo println("This will remove:") if keep_incus println(" - registry entry for '" + name + "' in " + cfg_path) println(" (incus container kept: --keep-incus)") else println(" - incus container '" + name + "' (project 'repoman')") println(" - registry entry for '" + name + "' in " + cfg_path) end if println("Your source repository at " + repo_path + " on the host will NOT be touched.") let proceed: bool = console.confirm_default_no("continue?") if not proceed println("aborted") return 4 end if end if // Open log AFTER the user has confirmed. let _ol: bool = log.open_log(reg.defaults.logdir, name, "remove") // Step 1: delete the incus container (unless --keep-incus). if not keep_incus log.write("==> incus delete --force " + name) let dr = incus.delete_container(reg.defaults.incus_project, name) if rg.is_err(dr) log.write("repoman: error: " + rg.unwrap_err(dr)) log.write("hint: pass --keep-incus to remove from registry only, or fix the incus error and retry") return 1 end if end if // Step 2: remove from registry. If this fails after a successful incus // delete, the container is gone but the registry still has the entry — // surface that explicitly so the user knows to clean up manually. let reg2_r = config.remove_project(reg, name) if rg.is_err(reg2_r) log.write("repoman: error: " + rg.unwrap_err(reg2_r)) if not keep_incus log.write("warning: incus container '" + name + "' was deleted but the registry still has its entry; remove manually from " + cfg_path) end if return 1 end if let saved = config.save(rg.unwrap_ok(reg2_r), cfg_path) if rg.is_err(saved) log.write("repoman: error: " + rg.unwrap_err(saved)) if not keep_incus log.write("warning: incus container '" + name + "' was deleted but the registry write failed; remove manually from " + cfg_path) end if return 1 end if log.write("==> removed '" + name + "'") return 0 end cmd_remove fn cmd_shell(argv: [string]): int let parser: flag.FlagParser = flag.flag_parser_from(argv) flag.application(parser, "repoman shell") flag.description(parser, "Open a login shell inside a project's container") let _c = flag.string_flag(parser, "cwd", '\0', "", "override the working directory inside the container") if not flag.parse(parser) console.printErr("repoman: error: " + flag.error(parser)) return 2 end if let positionals: [string] = flag.positional_args(parser) if positionals.length() != 1 console.printErr("repoman: error: 'shell' takes exactly one positional argument: ") return 2 end if let name: string = positionals[0] let cwd_flag: string = flag.get_string(parser, "cwd") let home: string = env.get_env_or("HOME", "") if str.length(home) == 0 console.printErr("repoman: error: HOME is not set") return 3 end if let reg_r = config.load_or_init(home) if rg.is_err(reg_r) console.printErr("repoman: error: " + rg.unwrap_err(reg_r)) return 3 end if let reg: config.Registry = rg.unwrap_ok(reg_r) // Look up the project to resolve the default cwd. let pn: int = reg.projects.length() mut found: int = -1 mut i: int = 0 while i < pn if reg.projects[i].name == name found = i end if i = i + 1 end while if found < 0 console.printErr("repoman: error: '" + name + "' not in registry") return 1 end if let proj: config.Project = reg.projects[found] // Resolve cwd: --cwd flag, or /. mut cwd: string = cwd_flag if str.length(cwd) == 0 let repos_root: string = paths.expand_home(reg.defaults.repos_root) cwd = paths.join(repos_root, proj.repo) end if // Resolve UID by shelling to `id -u`. let uid_r = incus.host_uid() if rg.is_err(uid_r) console.printErr("repoman: error: " + rg.unwrap_err(uid_r)) return 1 end if let uid: string = rg.unwrap_ok(uid_r) // Build incus exec argv. Note: process_run_exec auto-prepends "incus" as // argv[0], so the args list passed in does NOT include the program name. let inc_args: [string] = [ "exec", "--project", reg.defaults.incus_project, "--user", uid, "--cwd", cwd, "--env", "HOME=" + home, name, "--", "bash", "-l" ] // Replace our process (no zombie repoman waiting on the shell). let _x: int = p.process_run_exec("incus", inc_args) // Only reached if exec failed. console.printErr("repoman: error: failed to exec incus") return 1 end cmd_shell fn cmd_setup(argv: [string]): int return setup.cmd_setup(argv) end cmd_setup fn cmd_profile(argv: [string]): int if argv.length() == 0 console.printErr("repoman: error: 'profile' requires a subcommand: list | install | diff | remove | show") return 2 end if let verb: string = argv[0] // Slice argv[1..] for the verb's own parser let n: int = argv.length() mut rest: [string] = new [string](n - 1) mut i: int = 0 while i < n - 1 rest[i] = argv[i + 1] i = i + 1 end while if verb == "list" return cmd_profile_list(rest) end if if verb == "install" return cmd_profile_install(rest) end if if verb == "diff" return cmd_profile_diff(rest) end if if verb == "remove" return cmd_profile_remove(rest) end if if verb == "show" return cmd_profile_show(rest) end if console.printErr("repoman: error: unknown profile subcommand: " + verb) return 2 end cmd_profile fn build_host_facts(): profile.HostFacts let home: string = env.get_env_or("HOME", "") let user: string = env.get_env_or("USER", "") let reg_r = config.load_or_init(home) mut lan_ip: string = "" if rg.is_ok(reg_r) lan_ip = rg.unwrap_ok(reg_r).host.lan_ip end if return profile.HostFacts { lan_ip: lan_ip, user: user, home: home } end build_host_facts fn cmd_profile_list(argv: [string]): int let home: string = env.get_env_or("HOME", "") let entries = profile.list_all(home) println("NAME SOURCE INSTALLED") let n: int = entries.length() mut i: int = 0 while i < n let e = entries[i] mut inst: string = "no" if e.installed inst = "yes" end if println(e.name + " " + e.source + " " + inst) i = i + 1 end while return 0 end cmd_profile_list fn cmd_profile_install(argv: [string]): int let host = build_host_facts() let home: string = host.home if argv.length() == 0 console.printErr("repoman: error: 'profile install' requires or --all") return 2 end if if argv[0] == "--all" let entries = profile.list_all(home) let n: int = entries.length() mut i: int = 0 mut errs: int = 0 while i < n let e = entries[i] println("==> install " + e.name + " (source: " + e.source + ")") let r = profile.install(e.name, home, host) if rg.is_err(r) console.printErr(" error: " + rg.unwrap_err(r)) errs = errs + 1 end if i = i + 1 end while if errs > 0 return 1 end if return 0 end if let name: string = argv[0] let r = profile.install(name, home, host) if rg.is_err(r) console.printErr("repoman: error: " + rg.unwrap_err(r)) return 1 end if println("==> installed " + name) return 0 end cmd_profile_install fn cmd_profile_diff(argv: [string]): int if argv.length() == 0 console.printErr("repoman: error: 'profile diff' requires ") return 2 end if let host = build_host_facts() let r = profile.diff(argv[0], host.home, host) if rg.is_err(r) console.printErr("repoman: error: " + rg.unwrap_err(r)) return 3 end if println(rg.unwrap_ok(r)) return 0 end cmd_profile_diff fn cmd_profile_remove(argv: [string]): int if argv.length() == 0 console.printErr("repoman: error: 'profile remove' requires ") return 2 end if let r = profile.remove_profile(argv[0]) if rg.is_err(r) console.printErr("repoman: error: " + rg.unwrap_err(r)) return 1 end if println("==> removed " + argv[0] + " from incus") return 0 end cmd_profile_remove fn cmd_profile_show(argv: [string]): int if argv.length() == 0 console.printErr("repoman: error: 'profile show' requires ") return 2 end if let host = build_host_facts() let r = profile.show(argv[0], host.home, host) if rg.is_err(r) console.printErr("repoman: error: " + rg.unwrap_err(r)) return 3 end if println(rg.unwrap_ok(r)) return 0 end cmd_profile_show fn version_string(): string return "repoman 0.5.0" end version_string proc print_usage() console.printErr("Usage: repoman [args]") console.printErr("") console.printErr("Subcommands") console.printErr(" setup [--non-interactive]") console.printErr(" First-time host bootstrap: incus project, registry, host LAN IP detection.") console.printErr("") console.printErr(" new [--repo ] [--image ]") console.printErr(" Launch a container in the 'repoman' Incus project; bind ~/repos/.") console.printErr("") console.printErr(" profile {list|install|diff|remove|show} [] [--all]") console.printErr(" Manage Incus profiles from the repoman library.") console.printErr("") console.printErr(" sync [name] [--no-delete] [--dry-run]") console.printErr(" Mirror local repos to NFS backup (rsync --delete by default).") console.printErr("") console.printErr(" list") console.printErr(" Print a table of registered projects.") console.printErr("") console.printErr(" status [name]") console.printErr(" Show project state from registry + live incus query.") console.printErr("") console.printErr(" remove [--yes | -y] [--keep-incus]") console.printErr(" Delete the incus container and registry entry. Prompts for confirmation unless --yes.") console.printErr("") console.printErr(" shell [--cwd ]") console.printErr(" Open a bash login shell inside the project's container.") console.printErr("") console.printErr(" --version | -V") console.printErr(" --help | -h | help") end print_usage fn dispatch(argv: [string]): int // argv is the full process argv: [program, subcommand, ...] let n: int = argv.length() if n < 2 print_usage() return 0 end if let sub: string = argv[1] if sub == "--version" or sub == "-V" console.printErr(version_string()) return 0 end if if sub == "--help" or sub == "-h" or sub == "help" print_usage() return 0 end if // Slice argv[2..] for the subcommand parser mut rest: [string] = new [string](n - 2) mut i: int = 0 while i < n - 2 rest[i] = argv[i + 2] i = i + 1 end while if sub == "list" return cmd_list(rest) end if if sub == "new" return cmd_new(rest) end if if sub == "profile" return cmd_profile(rest) end if if sub == "remove" return cmd_remove(rest) end if if sub == "setup" return cmd_setup(rest) end if if sub == "shell" return cmd_shell(rest) end if if sub == "status" return cmd_status(rest) end if if sub == "sync" return cmd_sync(rest) end if console.printErr("repoman: error: unknown subcommand: " + sub) console.printErr("hint: try 'repoman --help'") return 2 end dispatch end module /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2026, Leafscale, LLC - https://www.leafscale.com Project: repoman Filename: src/config.reef Authors: Chris Tusa License: Description: Registry parse/serialize/migrate (schema 1 -> 2 -> 3) and per-project overrides ******************************************************************************/ module config import core.str import core.result_generic as rg import encoding.toml as toml import io.file as iofile import io.dir as iodir import io.path as iopath import paths export type Defaults type Host type Project type Override type Mount type Registry type EffectiveConfig fn parse_registry(toml_text: string): rg.Result[Registry, string] fn serialize_registry(reg: Registry): string fn parse_override(toml_text: string): rg.Result[Override, string] fn merge_with_defaults(name: string, repo: string, image_flag: string, ov: Override, d: Defaults): EffectiveConfig fn with_projects(reg: Registry, new_projects: [Project]): Registry fn add_project(reg: Registry, p: Project): rg.Result[Registry, string] fn update_last_sync(reg: Registry, name: string, ts: string): rg.Result[Registry, string] fn remove_project(reg: Registry, name: string): rg.Result[Registry, string] fn registry_path(home_dir: string): string fn default_registry(home_dir: string): Registry fn load_or_init(home_dir: string): rg.Result[Registry, string] fn save(reg: Registry, cfg_path: string): rg.Result[bool, string] end export type Host = struct lan_ip: string end Host type Defaults = struct repos_root: string backup_root: string logdir: string incus_project: string default_image: string profiles: [string] end Defaults type Project = struct name: string repo: string image: string profiles: [string] created: string last_sync: string backup: bool end Project type Mount = struct source: string path: string end Mount type Override = struct image: string profiles: [string] has_profiles: bool mounts: [Mount] env_keys: [string] env_values: [string] end Override type Registry = struct schema: int host: Host output: string defaults: Defaults projects: [Project] end Registry type EffectiveConfig = struct name: string repo: string repo_path: string image: string profiles: [string] mounts: [Mount] env_keys: [string] env_values: [string] end EffectiveConfig // Extract the host portion of an `http://:` URL string. // Returns "" if the input doesn't match that shape. fn extract_lan_ip_from_url(url: string): string let n: int = str.length(url) if n == 0 return "" end if let prefix: string = "http://" let prefix_len: int = 7 if not str.starts_with(url, prefix) return "" end if // Find ':' after the prefix mut i: int = prefix_len while i < n and url[i] != ':' i = i + 1 end while if i <= prefix_len return "" end if return str.substring(url, prefix_len, i - prefix_len) end extract_lan_ip_from_url // Parse a comma-separated TOML inline array of strings: `["a", "b"]`. // Tolerates whitespace and missing brackets; returns empty if input is empty. fn parse_string_array(raw: string): [string] let n: int = str.length(raw) if n == 0 return new [string](0) end if // Strip leading [ and trailing ] mut start: int = 0 mut end_idx: int = n if n > 0 and raw[0] == '[' start = 1 end if if end_idx > start and raw[end_idx - 1] == ']' end_idx = end_idx - 1 end if let inner: string = str.substring(raw, start, end_idx - start) // Split on commas, then trim quotes and whitespace from each element. let inner_len: int = str.length(inner) if inner_len == 0 return new [string](0) end if // Reef doesn't have a dynamic split-and-collect; use str.split with a max. mut parts: [string] = new [string](64) let count: int = str.split(inner, ',', parts, 64) mut result: [string] = new [string](count) mut i: int = 0 while i < count let p: string = str.trim_ws(parts[i]) // Strip surrounding double quotes if present let pl: int = str.length(p) if pl >= 2 and p[0] == '"' and p[pl - 1] == '"' result[i] = str.substring(p, 1, pl - 2) else result[i] = p end if i = i + 1 end while return result end parse_string_array fn parse_registry(toml_text: string): rg.Result[Registry, string] let doc: toml.TomlDoc = toml.toml_parse_doc(toml_text) if doc.truncated return @Result[Registry, string].Err("registry too large (>1024 entries)") end if let schema: int = toml.toml_get_int_doc(doc, "repoman.schema") if schema != 1 and schema != 2 and schema != 3 return @Result[Registry, string].Err("unsupported schema (expected 1, 2, or 3)") end if // [repoman].output: "quiet" (default) | "verbose" mut output: string = toml.toml_get_doc(doc, "repoman.output") if str.length(output) == 0 output = "quiet" end if if output != "quiet" and output != "verbose" return @Result[Registry, string].Err("invalid [repoman].output: " + output + " (expected 'quiet' or 'verbose')") end if // [defaults].logdir: ~/.local/state/repoman by default mut logdir: string = toml.toml_get_doc(doc, "defaults.logdir") if str.length(logdir) == 0 logdir = "~/.local/state/repoman" end if let defaults: Defaults = Defaults { repos_root: toml.toml_get_doc(doc, "defaults.repos_root"), backup_root: toml.toml_get_doc(doc, "defaults.backup_root"), logdir: logdir, incus_project: toml.toml_get_doc(doc, "defaults.incus_project"), default_image: toml.toml_get_doc(doc, "defaults.default_image"), profiles: parse_string_array(toml.toml_get_doc(doc, "defaults.profiles")) } let project_count: int = toml.toml_array_count(doc.keys, doc.count, "project") mut projects: [Project] = new [Project](project_count) mut i: int = 0 while i < project_count let p: Project = Project { name: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "name"), repo: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "repo"), image: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "image"), profiles: parse_string_array(toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "profiles")), created: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "created"), last_sync: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "last_sync"), backup: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "backup") != "false" } projects[i] = p i = i + 1 end while // [host].lan_ip — schema 3 reads it directly; schema 2 migration extracts from // the now-removed [defaults.llm.ollama_url]; schema 1 has no such value (empty). mut lan_ip: string = toml.toml_get_doc(doc, "host.lan_ip") if str.length(lan_ip) == 0 // Fall back: schema 2 migration via [defaults.llm.ollama_url] let old_url: string = toml.toml_get_doc(doc, "defaults.llm.ollama_url") if str.length(old_url) > 0 lan_ip = extract_lan_ip_from_url(old_url) end if end if let host: Host = Host { lan_ip: lan_ip } let reg: Registry = Registry { schema: 3, host: host, output: output, defaults: defaults, projects: projects } return @Result[Registry, string].Ok(reg) end parse_registry fn serialize_registry(reg: Registry): string let b: toml.TomlBuilder = toml.toml_builder() toml.toml_begin_table(b, "repoman") toml.toml_set_int(b, "schema", reg.schema) toml.toml_set_string(b, "output", reg.output) toml.toml_begin_table(b, "host") toml.toml_set_string(b, "lan_ip", reg.host.lan_ip) toml.toml_begin_table(b, "defaults") toml.toml_set_string(b, "repos_root", reg.defaults.repos_root) toml.toml_set_string(b, "backup_root", reg.defaults.backup_root) toml.toml_set_string(b, "logdir", reg.defaults.logdir) toml.toml_set_string(b, "incus_project", reg.defaults.incus_project) toml.toml_set_string(b, "default_image", reg.defaults.default_image) toml.toml_set_string_array(b, "profiles", reg.defaults.profiles) let pn: int = reg.projects.length() mut i: int = 0 while i < pn let p: Project = reg.projects[i] toml.toml_array_append_table(b, "project") toml.toml_set_string(b, "name", p.name) toml.toml_set_string(b, "repo", p.repo) toml.toml_set_string(b, "image", p.image) toml.toml_set_string_array(b, "profiles", p.profiles) toml.toml_set_string(b, "created", p.created) toml.toml_set_string(b, "last_sync", p.last_sync) toml.toml_set_bool(b, "backup", p.backup) i = i + 1 end while return toml.toml_render(b) end serialize_registry fn parse_override(toml_text: string): rg.Result[Override, string] let doc: toml.TomlDoc = toml.toml_parse_doc(toml_text) if doc.truncated return @Result[Override, string].Err("override too large") end if let image: string = toml.toml_get_doc(doc, "container.image") let profiles_raw: string = toml.toml_get_doc(doc, "container.profiles") let has_profiles: bool = toml.toml_has_key_doc(doc, "container.profiles") let profiles: [string] = parse_string_array(profiles_raw) let mount_count: int = toml.toml_array_count(doc.keys, doc.count, "mount") mut mounts: [Mount] = new [Mount](mount_count) mut i: int = 0 while i < mount_count mounts[i] = Mount { source: toml.toml_array_get(doc.keys, doc.values, doc.count, "mount", i, "source"), path: toml.toml_array_get(doc.keys, doc.values, doc.count, "mount", i, "path") } i = i + 1 end while // Walk doc.keys for entries starting with "env." mut env_keys_buf: [string] = new [string](128) mut env_vals_buf: [string] = new [string](128) mut env_count: int = 0 mut k: int = 0 while k < doc.count let key: string = doc.keys[k] if str.starts_with(key, "env.") if env_count < 128 env_keys_buf[env_count] = str.substring(key, 4, str.length(key) - 4) env_vals_buf[env_count] = doc.values[k] env_count = env_count + 1 end if end if k = k + 1 end while mut env_keys: [string] = new [string](env_count) mut env_vals: [string] = new [string](env_count) mut j: int = 0 while j < env_count env_keys[j] = env_keys_buf[j] env_vals[j] = env_vals_buf[j] j = j + 1 end while let ov: Override = Override { image: image, profiles: profiles, has_profiles: has_profiles, mounts: mounts, env_keys: env_keys, env_values: env_vals } return @Result[Override, string].Ok(ov) end parse_override fn merge_with_defaults(name: string, repo: string, image_flag: string, ov: Override, d: Defaults): EffectiveConfig let repos_root_expanded: string = paths.expand_home(d.repos_root) let repo_path: string = paths.join(repos_root_expanded, repo) // Image priority: flag → override → defaults mut image: string = d.default_image if str.length(ov.image) > 0 image = ov.image end if if str.length(image_flag) > 0 image = image_flag end if // Profiles: override replaces defaults when has_profiles, else defaults mut profiles: [string] = d.profiles if ov.has_profiles profiles = ov.profiles end if // Mounts: [auto repo bind] ++ override.mounts let ov_mount_count: int = ov.mounts.length() mut mounts: [Mount] = new [Mount](1 + ov_mount_count) mounts[0] = Mount { source: repo_path, path: repo_path } mut i: int = 0 while i < ov_mount_count // Expand ~ in mount source for host paths let m: Mount = ov.mounts[i] mounts[i + 1] = Mount { source: paths.expand_home(m.source), path: m.path } i = i + 1 end while return EffectiveConfig { name: name, repo: repo, repo_path: repo_path, image: image, profiles: profiles, mounts: mounts, env_keys: ov.env_keys, env_values: ov.env_values } end merge_with_defaults fn add_project(reg: Registry, p: Project): rg.Result[Registry, string] let n: int = reg.projects.length() mut i: int = 0 while i < n if reg.projects[i].name == p.name return @Result[Registry, string].Err("project already exists: " + p.name) end if i = i + 1 end while mut new_projects: [Project] = new [Project](n + 1) mut k: int = 0 while k < n new_projects[k] = reg.projects[k] k = k + 1 end while new_projects[n] = p return @Result[Registry, string].Ok(with_projects(reg, new_projects)) end add_project fn update_last_sync(reg: Registry, name: string, ts: string): rg.Result[Registry, string] let n: int = reg.projects.length() mut found: int = -1 mut i: int = 0 while i < n if reg.projects[i].name == name found = i end if i = i + 1 end while if found < 0 return @Result[Registry, string].Err("project not in registry: " + name) end if mut new_projects: [Project] = new [Project](n) mut k: int = 0 while k < n if k == found let old: Project = reg.projects[k] new_projects[k] = Project { name: old.name, repo: old.repo, image: old.image, profiles: old.profiles, created: old.created, last_sync: ts, backup: old.backup } else new_projects[k] = reg.projects[k] end if k = k + 1 end while return @Result[Registry, string].Ok(with_projects(reg, new_projects)) end update_last_sync // Build a new Registry from an existing one plus a replacement projects list. // Single source of truth for "preserve all top-level Registry fields when // rewriting projects" — keeps fields from drifting silently across the // add/update/remove call sites. fn with_projects(reg: Registry, new_projects: [Project]): Registry return Registry { schema: reg.schema, host: reg.host, output: reg.output, defaults: reg.defaults, projects: new_projects } end with_projects fn remove_project(reg: Registry, name: string): rg.Result[Registry, string] let n: int = reg.projects.length() mut found: int = -1 mut i: int = 0 while i < n if reg.projects[i].name == name found = i end if i = i + 1 end while if found < 0 return @Result[Registry, string].Err("project not in registry: " + name) end if mut new_projects: [Project] = new [Project](n - 1) mut k: int = 0 mut j: int = 0 while k < n if k != found new_projects[j] = reg.projects[k] j = j + 1 end if k = k + 1 end while return @Result[Registry, string].Ok(with_projects(reg, new_projects)) end remove_project fn registry_path(home_dir: string): string let cfg_dir: string = paths.join(home_dir, ".config/repoman") return paths.join(cfg_dir, "repoman.toml") end registry_path fn default_registry(home_dir: string): Registry let repos_root: string = paths.join(home_dir, "repos") let logdir: string = paths.join(home_dir, ".local/state/repoman") return Registry { schema: 3, host: Host { lan_ip: "" }, output: "quiet", defaults: Defaults { repos_root: repos_root, backup_root: "/nfs/repos", logdir: logdir, incus_project: "repoman", default_image: "images:ubuntu/26.04/cloud", profiles: ["default", "claude-share"] }, projects: new [Project](0) } end default_registry fn load_or_init(home_dir: string): rg.Result[Registry, string] let cfg_path: string = registry_path(home_dir) let cfg_dir: string = iopath.dirname(cfg_path) // Ensure ~/.config/repoman/ exists if not iodir.dir_exists(cfg_dir) if not iodir.create_dir_all(cfg_dir) return @Result[Registry, string].Err("cannot create config dir: " + cfg_dir) end if end if if iofile.fileExists(cfg_path) let contents: string = iofile.readFile(cfg_path) return parse_registry(contents) end if // Init: write default registry. let reg: Registry = default_registry(home_dir) let saved_r = save(reg, cfg_path) if rg.is_err(saved_r) return @Result[Registry, string].Err(rg.unwrap_err(saved_r)) end if return @Result[Registry, string].Ok(reg) end load_or_init // Atomic write: writeFile(.tmp) → fsync(.tmp) → rename(.tmp, target). // If any step fails, returns Err naming the failing step. fn save(reg: Registry, cfg_path: string): rg.Result[bool, string] let serialized: string = serialize_registry(reg) let tmp_path: string = cfg_path + ".tmp" if not iofile.writeFile(tmp_path, serialized) return @Result[bool, string].Err("write failed: " + tmp_path) end if if not iofile.fsync(tmp_path) // Best effort: clean up tmp let _d: bool = iofile.deleteFile(tmp_path) return @Result[bool, string].Err("fsync failed: " + tmp_path) end if if not iofile.rename(tmp_path, cfg_path) let _d: bool = iofile.deleteFile(tmp_path) return @Result[bool, string].Err("rename failed: " + tmp_path + " → " + cfg_path) end if return @Result[bool, string].Ok(true) end save end module /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2026, Leafscale, LLC - https://www.leafscale.com Project: repoman Filename: src/hermes.reef Authors: Chris Tusa License: Description: Hermes classify/paths helpers (library only; not wired to a CLI subcommand) ******************************************************************************/ module hermes import core.str import core.result_generic as rg import core.convert as convert import io.dir as iodir import sys.process as p import paths export fn state_dir_for(home_dir: string, container_name: string): string fn default_seed_list(): [string] fn classify_seed_entry(name: string): int fn SEED_KIND_COPY(): int fn SEED_KIND_SYMLINK(): int fn seed_data_dir(source: string, dest: string, seed: [string]): rg.Result[bool, string] fn purge_data_dir(dest: string): rg.Result[bool, string] end export // Layout under ~/.local/share/repoman/hermes// fn state_dir_for(home_dir: string, container_name: string): string let base: string = paths.join(home_dir, ".local/share/repoman/hermes") return paths.join(base, container_name) end state_dir_for // Default selective-seed list: what to copy/symlink from the host's // ~/.hermes/ into a per-container data dir. Per-instance state // (sessions/, memories/, state.db, etc.) is *not* in this list. fn default_seed_list(): [string] return [ ".env", "config.yaml", "SOUL.md", "skills/", "hooks/" ] end default_seed_list // Constants exposed to the applier so it knows whether to copy or symlink // each seed entry. fn SEED_KIND_COPY(): int return 1 end SEED_KIND_COPY fn SEED_KIND_SYMLINK(): int return 2 end SEED_KIND_SYMLINK // Classify a seed entry. v0.3 always returns COPY: an earlier design used // SYMLINK for runtime dirs (hermes-agent/, node/, bin/) so host upgrades // would auto-apply, but the symlinks can't resolve through the bind-mount // boundary inside the container (FilesystemLoop). Spec O-3 anticipated // this and named copy as the fallback. Cost: container recreation needed // after host hermes upgrades. fn classify_seed_entry(name: string): int return SEED_KIND_COPY() end classify_seed_entry // Strip a trailing slash from a string ("hermes-agent/" → "hermes-agent"). // Used to normalize seed-list entries before paths.join. fn strip_trailing_slash(s: string): string let n: int = str.length(s) if n > 0 and s[n - 1] == '/' return str.substring(s, 0, n - 1) end if return s end strip_trailing_slash // Recursively copy `src` to `dst`. We don't have a built-in recursive copy // in the reef stdlib, so we shell out to `cp -a`. argv-list spawn — never // shell — so user-supplied paths can't escape. fn cp_recursive(src: string, dst: string): bool let pid: int = p.process_run("cp", ["-a", src, dst]) if pid < 0 return false end if return p.process_wait(pid) == 0 end cp_recursive // Create a symlink at `link` pointing to `target` (absolute path). fn make_symlink(target: string, link: string): bool let pid: int = p.process_run("ln", ["-sfn", target, link]) if pid < 0 return false end if return p.process_wait(pid) == 0 end make_symlink // Selectively seed `source` into `dest` per the seed list. // Idempotent for COPY entries (cp -a overwrites); idempotent for // SYMLINK entries (ln -sfn replaces existing links). // Returns Err on the first failure with a message naming the offending entry. fn seed_data_dir(source: string, dest: string, seed: [string]): rg.Result[bool, string] if not iodir.dir_exists(source) return @Result[bool, string].Err("hermes source dir not found: " + source) end if if not iodir.create_dir_all(dest) return @Result[bool, string].Err("cannot create dest dir: " + dest) end if let n: int = seed.length() mut i: int = 0 while i < n let entry: string = seed[i] let normalized: string = strip_trailing_slash(entry) let src_path: string = paths.join(source, normalized) let dst_path: string = paths.join(dest, normalized) let kind: int = classify_seed_entry(entry) if kind == SEED_KIND_COPY() if not cp_recursive(src_path, dst_path) return @Result[bool, string].Err("copy failed: " + entry) end if else if not make_symlink(src_path, dst_path) return @Result[bool, string].Err("symlink failed: " + entry) end if end if i = i + 1 end while return @Result[bool, string].Ok(true) end seed_data_dir // Recursively delete the per-container hermes data dir. Loud; only called // from `repoman remove --purge-hermes`. fn purge_data_dir(dest: string): rg.Result[bool, string] if not iodir.dir_exists(dest) return @Result[bool, string].Ok(true) // nothing to do end if let pid: int = p.process_run("rm", ["-rf", dest]) if pid < 0 return @Result[bool, string].Err("failed to spawn rm") end if let exit: int = p.process_wait(pid) if exit == 0 return @Result[bool, string].Ok(true) end if return @Result[bool, string].Err("rm -rf exited " + convert.to_string(exit)) end purge_data_dir end module /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2026, Leafscale, LLC - https://www.leafscale.com Project: repoman Filename: src/incus.reef Authors: Chris Tusa License: Description: Wrappers over the incus CLI (launch, delete, profile ops, device add with shift) ******************************************************************************/ module incus import core.str import sys.process as p import sys.fd as fd import core.result_generic as rg import core.convert as convert export type CaptureResult fn process_run_capture(program: string, args: [string]): CaptureResult fn process_run_silent(program: string, args: [string]): int fn validate_name(name: string): bool fn project_ensure(project: string, verbose: bool): rg.Result[bool, string] fn project_present(project: string): rg.Result[bool, string] fn container_exists(project: string, name: string, verbose: bool): rg.Result[bool, string] fn launch(project: string, name: string, image: string, profiles: [string]): rg.Result[bool, string] fn device_add_disk(project: string, name: string, dev: string, src: string, dst: string): rg.Result[bool, string] fn set_env_var(project: string, name: string, key: string, val: string): rg.Result[bool, string] fn restart(project: string, name: string): rg.Result[bool, string] fn container_state(project: string, name: string): rg.Result[string, string] fn delete_container(project: string, name: string): rg.Result[bool, string] fn delete_profile(project: string, name: string): rg.Result[bool, string] fn host_uid(): rg.Result[string, string] fn profile_exists(project: string, name: string): rg.Result[bool, string] fn profile_create_or_edit(project: string, name: string, yaml: string): rg.Result[bool, string] fn profile_get(project: string, name: string): rg.Result[string, string] fn device_add_disk_opts(project: string, name: string, dev: string, src: string, dst: string, opts: [string]): rg.Result[bool, string] end export // Spawn a program with both stdout and stderr redirected to /dev/null in the // child. Used for existence-check probes where the upstream command's error // output would confuse the user (e.g., `incus project show foo` writing // "Error: Project not found" to stderr when foo doesn't exist — we already // know that, the missing-ness is the signal we wanted). // // Implementation: fork → in child, open /dev/null and dup2 it over fd 1/2, // then exec the program. Returns the child PID; caller waits as usual. fn process_run_silent(program: string, args: [string]): int let pid: int = p.process_fork() if pid < 0 return -1 end if if pid == 0 let dev_null: int = fd.fd_open("/dev/null", fd.O_WRONLY(), 0) if dev_null >= 0 let _o: int = fd.fd_dup2(dev_null, fd.STDOUT()) let _e: int = fd.fd_dup2(dev_null, fd.STDERR()) let _c: int = fd.fd_close(dev_null) end if let _x: int = p.process_run_exec(program, args) p.exit_now(127) end if return pid end process_run_silent type CaptureResult = struct exit_code: int stdout: string end CaptureResult // Spawn `program args` and capture the child's stdout to a string. // stderr passes through to parent terminal. Returns exit code + stdout. // On any setup failure (fork, pipe, exec), exit_code is non-zero and // stdout is empty. fn process_run_capture(program: string, args: [string]): CaptureResult let pipe_fds: [int] = fd.fd_pipe() if pipe_fds.length() != 2 return CaptureResult { exit_code: -1, stdout: "" } end if let read_fd: int = pipe_fds[0] let write_fd: int = pipe_fds[1] let pid: int = p.process_fork() if pid < 0 let _r: int = fd.fd_close(read_fd) let _w: int = fd.fd_close(write_fd) return CaptureResult { exit_code: -1, stdout: "" } end if if pid == 0 // Child: dup write end of pipe over stdout, close both ends, exec. let _o: int = fd.fd_dup2(write_fd, fd.STDOUT()) let _c1: int = fd.fd_close(read_fd) let _c2: int = fd.fd_close(write_fd) let _x: int = p.process_run_exec(program, args) p.exit_now(127) end if // Parent: close write end, read until EOF, wait. let _cw: int = fd.fd_close(write_fd) mut output: string = "" let chunk_size: int = 4096 mut keep_reading: bool = true while keep_reading let chunk: string = fd.fd_read(read_fd, chunk_size) if str.length(chunk) == 0 keep_reading = false else output = output + chunk end if end while let _cr: int = fd.fd_close(read_fd) let exit: int = p.process_wait(pid) return CaptureResult { exit_code: exit, stdout: output } end process_run_capture // Spawn `program args` with the given string written to the child's stdin. // stdout/stderr inherit the parent terminal. Returns exit code. // On any setup failure (fork, pipe, exec), returns non-zero. fn process_run_with_stdin(program: string, args: [string], input: string): int let pipe_fds: [int] = fd.fd_pipe() if pipe_fds.length() != 2 return -1 end if let read_fd: int = pipe_fds[0] let write_fd: int = pipe_fds[1] let pid: int = p.process_fork() if pid < 0 let _r: int = fd.fd_close(read_fd) let _w: int = fd.fd_close(write_fd) return -1 end if if pid == 0 // Child: dup read end of pipe over stdin, close both ends, exec. let _i: int = fd.fd_dup2(read_fd, fd.STDIN()) let _c1: int = fd.fd_close(read_fd) let _c2: int = fd.fd_close(write_fd) let _x: int = p.process_run_exec(program, args) p.exit_now(127) end if // Parent: close read end, write input, close write, wait. let _cr: int = fd.fd_close(read_fd) let _wn: int = fd.fd_write(write_fd, input) let _cw: int = fd.fd_close(write_fd) return p.process_wait(pid) end process_run_with_stdin // Query incus for the container's state. Uses `incus list --project

// --format csv -c s ` which outputs just the STATE column ("RUNNING", // "STOPPED", etc.) for matching containers. Empty output (no match) means // the container doesn't exist in this project; we return "MISSING". fn container_state(project: string, name: string): rg.Result[string, string] let cap: CaptureResult = process_run_capture("incus", [ "list", "--project", project, "--format", "csv", "-c", "s", name ]) if cap.exit_code != 0 return @Result[string, string].Err("incus list exited with code " + convert.to_string(cap.exit_code)) end if // Trim trailing newline + whitespace from stdout. let trimmed: string = str.trim_ws(cap.stdout) if str.length(trimmed) == 0 return @Result[string, string].Ok("MISSING") end if return @Result[string, string].Ok(trimmed) end container_state // Get the current process's effective UID as a decimal string. Used by // `repoman shell` to pass --user to incus exec so the in-container shell // runs as the host user (matched against claude-share's UID-mapping). // Reef stdlib doesn't expose getuid() publicly today, so we shell out to // `id -u` and parse stdout. fn host_uid(): rg.Result[string, string] let cap: CaptureResult = process_run_capture("id", ["-u"]) if cap.exit_code != 0 return @Result[string, string].Err("id -u exited with code " + convert.to_string(cap.exit_code)) end if let trimmed: string = str.trim_ws(cap.stdout) if str.length(trimmed) == 0 return @Result[string, string].Err("id -u returned empty output") end if return @Result[string, string].Ok(trimmed) end host_uid fn is_lower_alnum_or_hyphen(c: char): bool if c >= 'a' and c <= 'z' return true end if if c >= '0' and c <= '9' return true end if if c == '-' return true end if return false end is_lower_alnum_or_hyphen fn validate_name(name: string): bool let n: int = str.length(name) if n == 0 return false end if if n > 63 return false end if if name[0] == '-' return false end if mut i: int = 0 while i < n if not is_lower_alnum_or_hyphen(name[i]) return false end if i = i + 1 end while return true end validate_name // Run `incus `. Returns Ok(true) on exit 0, Err with a brief diagnostic // on non-zero. Stderr inherits the parent terminal. fn run_incus(args: [string]): rg.Result[bool, string] let pid: int = p.process_run("incus", args) if pid < 0 return @Result[bool, string].Err("failed to spawn 'incus' (is it installed?)") end if let exit: int = p.process_wait(pid) if exit == 0 return @Result[bool, string].Ok(true) end if return @Result[bool, string].Err("incus exited with code " + convert.to_string(exit)) end run_incus fn project_ensure(project: string, verbose: bool): rg.Result[bool, string] // `incus project show ` exits 0 if it exists, non-0 otherwise. // Quiet mode silences the YAML dump on success and the "Error: // Project not found" stderr on failure; verbose mode passes through. mut pid: int = -1 if verbose pid = p.process_run("incus", ["project", "show", project]) else pid = process_run_silent("incus", ["project", "show", project]) end if let exit: int = p.process_wait(pid) if exit == 0 return @Result[bool, string].Ok(true) end if // Create with all features disabled so the project is a pure container // namespace and shares images/profiles/networks/storage with the default // project. Without this, profiles like 'claude-share' that live in the // default project would be invisible to containers in this project. return run_incus([ "project", "create", project, "-c", "features.images=false", "-c", "features.profiles=false", "-c", "features.networks=false", "-c", "features.storage.volumes=false", "-c", "features.storage.buckets=false" ]) end project_ensure fn project_present(project: string): rg.Result[bool, string] let pid: int = process_run_silent("incus", ["project", "show", project]) if pid < 0 return @Result[bool, string].Err("failed to spawn 'incus project show'") end if let exit: int = p.process_wait(pid) if exit == 0 return @Result[bool, string].Ok(true) end if return @Result[bool, string].Ok(false) end project_present fn container_exists(project: string, name: string, verbose: bool): rg.Result[bool, string] // `incus info --project

` exits 0 if it exists. // Quiet silences both info dump and error output; verbose passes through. mut pid: int = -1 if verbose pid = p.process_run("incus", ["info", "--project", project, name]) else pid = process_run_silent("incus", ["info", "--project", project, name]) end if let exit: int = p.process_wait(pid) if exit == 0 return @Result[bool, string].Ok(true) end if return @Result[bool, string].Ok(false) end container_exists fn launch(project: string, name: string, image: string, profiles: [string]): rg.Result[bool, string] let pn: int = profiles.length() // argv shape: ["launch", "--project", project, ("--profile" P)*, image, name] mut args: [string] = new [string](3 + 2 * pn + 2) args[0] = "launch" args[1] = "--project" args[2] = project mut i: int = 0 while i < pn args[3 + i * 2] = "--profile" args[3 + i * 2 + 1] = profiles[i] i = i + 1 end while args[3 + 2 * pn] = image args[3 + 2 * pn + 1] = name return run_incus(args) end launch fn device_add_disk(project: string, name: string, dev: string, src: string, dst: string): rg.Result[bool, string] return run_incus([ "config", "device", "add", "--project", project, name, dev, "disk", "source=" + src, "path=" + dst ]) end device_add_disk // Like device_add_disk but with extra options like ["shift=true", // "readonly=true"] appended to the incus argv. fn device_add_disk_opts(project: string, name: string, dev: string, src: string, dst: string, opts: [string]): rg.Result[bool, string] let on: int = opts.length() // Final argv: ["config", "device", "add", "--project", P, NAME, DEV, "disk", // "source=...", "path=...", opts...] mut args: [string] = new [string](10 + on) args[0] = "config" args[1] = "device" args[2] = "add" args[3] = "--project" args[4] = project args[5] = name args[6] = dev args[7] = "disk" args[8] = "source=" + src args[9] = "path=" + dst mut i: int = 0 while i < on args[10 + i] = opts[i] i = i + 1 end while return run_incus(args) end device_add_disk_opts fn set_env_var(project: string, name: string, key: string, val: string): rg.Result[bool, string] return run_incus([ "config", "set", "--project", project, name, "environment." + key + "=" + val ]) end set_env_var fn restart(project: string, name: string): rg.Result[bool, string] return run_incus(["restart", "--project", project, name]) end restart fn delete_container(project: string, name: string): rg.Result[bool, string] return run_incus(["delete", "--project", project, "--force", name]) end delete_container fn delete_profile(project: string, name: string): rg.Result[bool, string] return run_incus(["profile", "delete", "--project", project, name]) end delete_profile // Returns Ok(true) if the named profile exists in the given project, // Ok(false) otherwise. Errors only on subprocess failure. fn profile_exists(project: string, name: string): rg.Result[bool, string] let pid: int = process_run_silent("incus", [ "profile", "show", "--project", project, name ]) if pid < 0 return @Result[bool, string].Err("failed to spawn 'incus profile show'") end if let exit: int = p.process_wait(pid) if exit == 0 return @Result[bool, string].Ok(true) end if return @Result[bool, string].Ok(false) end profile_exists // Idempotent: ensure the profile exists with the given YAML body. // Step 1: try `incus profile create` (no-op error if exists). // Step 2: `incus profile edit` reads stdin and replaces the profile body. fn profile_create_or_edit(project: string, name: string, yaml: string): rg.Result[bool, string] // Step 1: create (silent on failure — profile may already exist) let create_pid: int = process_run_silent("incus", [ "profile", "create", "--project", project, name ]) let _ce: int = p.process_wait(create_pid) // Step 2: edit with YAML on stdin let edit_exit: int = process_run_with_stdin("incus", [ "profile", "edit", "--project", project, name ], yaml) if edit_exit == 0 return @Result[bool, string].Ok(true) end if return @Result[bool, string].Err("incus profile edit exited with code " + convert.to_string(edit_exit)) end profile_create_or_edit // Returns the YAML body of the named profile (the same output as // `incus profile show `). Errors if the profile doesn't // exist or the subprocess fails. fn profile_get(project: string, name: string): rg.Result[string, string] let cap: CaptureResult = process_run_capture("incus", [ "profile", "show", "--project", project, name ]) if cap.exit_code != 0 return @Result[string, string].Err("incus profile show exited " + convert.to_string(cap.exit_code)) end if return @Result[string, string].Ok(cap.stdout) end profile_get end module /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2026, Leafscale, LLC - https://www.leafscale.com Project: repoman Filename: src/log.reef Authors: Chris Tusa License: Description: Per-invocation log file writer ******************************************************************************/ module log import core.str import io.console as console import io.file as iofile import io.dir as iodir import paths export fn open_log(logdir: string, project: string, verb: string): bool proc write(msg: string) end export // Module-level state: the path of the currently-open log file, and a flag // indicating whether file writes are enabled. Both are set by open_log; if // open fails (cannot create logdir, cannot truncate file), log_enabled stays // false and write() degrades to stderr-only. mut log_path: string = "" mut log_enabled: bool = false // Prepare a log file for the current invocation. Naming: /-.log. // Truncates any existing file. Returns false on setup failure (printing a // warning to stderr); the caller should still proceed — write() will fall // back to stderr-only output. fn open_log(logdir: string, project: string, verb: string): bool let expanded: string = paths.expand_home(logdir) if not iodir.dir_exists(expanded) if not iodir.create_dir_all(expanded) console.printErr("repoman: warning: cannot create logdir " + expanded + "; file logging disabled") log_enabled = false return false end if end if let filename: string = project + "-" + verb + ".log" let path: string = paths.join(expanded, filename) // Truncate (or create) the file by writing an empty string. if not iofile.writeFile(path, "") console.printErr("repoman: warning: cannot open " + path + " for writing; file logging disabled") log_enabled = false return false end if log_path = path log_enabled = true return true end open_log // Print a message to stderr (always) and append it to the open log file // (when logging is enabled). Mirrors what the user sees on stderr into // the log file for after-the-fact troubleshooting. proc write(msg: string) console.printErr(msg) if log_enabled let _w: bool = iofile.appendFile(log_path, msg + "\n") end if end write end module /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2026, Leafscale, LLC - https://www.leafscale.com Project: repoman Filename: src/main.reef Authors: Chris Tusa License: Description: Entry point — argv dispatch to subcommand handlers ******************************************************************************/ import cli import sys.args as args import sys.process as p proc main() let n: int = args.count() mut argv: [string] = new [string](n) mut i: int = 0 while i < n argv[i] = args.get(i) i = i + 1 end while let code: int = cli.dispatch(argv) p.exit_now(code) end main /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2026, Leafscale, LLC - https://www.leafscale.com Project: repoman Filename: src/paths.reef Authors: Chris Tusa License: Description: Path helpers (home expansion, registry path, log path) ******************************************************************************/ module paths import io.path as iopath import io.dir as iodir import io.file as iofile export fn expand_home(p: string): string fn join(a: string, b: string): string fn exists(p: string): bool fn is_dir(p: string): bool end export fn expand_home(p: string): string return iopath.expand_home(p) end expand_home fn join(a: string, b: string): string return iopath.join(a, b) end join fn exists(p: string): bool if iofile.fileExists(p) return true end if return iodir.dir_exists(p) end exists fn is_dir(p: string): bool return iodir.is_directory(p) end is_dir end module /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2026, Leafscale, LLC - https://www.leafscale.com Project: repoman Filename: src/profile.reef Authors: Chris Tusa License: Description: Profile library: vendor/user shadowing and ${HOST_LAN_IP}/${USER}/${HOME} substitution ******************************************************************************/ module profile import core.str import core.result_generic as rg import core.convert as convert import io.file as iofile import io.dir as iodir import sys.env import paths import incus export type ProfileEntry type HostFacts fn vendor_dir(): string fn user_dir(home_dir: string): string fn render(yaml: string, host: HostFacts): string fn lookup(name: string, home_dir: string): rg.Result[string, string] fn list_all(home_dir: string): [ProfileEntry] fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string] fn remove_profile(name: string): rg.Result[bool, string] fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string] fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string] end export // One entry from the profile library, populated by list_all. type ProfileEntry = struct name: string // e.g., "claude-share" source: string // "user", "vendor", or "user (shadows vendor)" file_path: string // resolved file path installed: bool // present in incus state end ProfileEntry // Substitution context for templated profile YAML. type HostFacts = struct lan_ip: string user: string home: string end HostFacts // (function bodies follow in subsequent tasks) fn vendor_dir(): string return "/usr/local/share/repoman/profiles" end vendor_dir fn user_dir(home_dir: string): string let suffix: string = ".config/repoman/profiles.d" if str.is_empty(home_dir) return "/" + suffix end if return paths.join(home_dir, suffix) end user_dir fn render(yaml: string, host: HostFacts): string let s1: string = str.replace(yaml, "\${HOST_LAN_IP}", host.lan_ip) let s2: string = str.replace(s1, "\${USER}", host.user) let s3: string = str.replace(s2, "\${HOME}", host.home) return s3 end render fn lookup(name: string, home_dir: string): rg.Result[string, string] let user_path: string = paths.join(user_dir(home_dir), name + ".yml") if iofile.fileExists(user_path) return @Result[string, string].Ok(user_path) end if let vendor_path: string = paths.join(vendor_dir(), name + ".yml") if iofile.fileExists(vendor_path) return @Result[string, string].Ok(vendor_path) end if return @Result[string, string].Err("profile not found: " + name + " (looked in " + user_path + " and " + vendor_path + ")") end lookup fn list_all(home_dir: string): [ProfileEntry] // Buffers for filename lists. Cap at 256 per dir; profile libraries // are not expected to be enormous. let max_files: int = 256 mut user_buf: [string] = new [string](max_files) mut vendor_buf: [string] = new [string](max_files) let u_dir: string = user_dir(home_dir) mut user_count: int = 0 if iodir.dir_exists(u_dir) user_count = iodir.list_dir(u_dir, user_buf, max_files) end if let v_dir: string = vendor_dir() mut vendor_count: int = 0 if iodir.dir_exists(v_dir) vendor_count = iodir.list_dir(v_dir, vendor_buf, max_files) end if // Pre-allocate result buffer at worst-case size. let cap: int = user_count + vendor_count mut entries_buf: [ProfileEntry] = new [ProfileEntry](cap) mut e_count: int = 0 // Pass 1: add all user-dir *.yml files mut i: int = 0 while i < user_count let fname: string = user_buf[i] if str.ends_with(fname, ".yml") let name: string = str.substring(fname, 0, str.length(fname) - 4) let path: string = paths.join(u_dir, fname) let installed_r = incus.profile_exists("default", name) mut installed: bool = false if rg.is_ok(installed_r) installed = rg.unwrap_ok(installed_r) end if entries_buf[e_count] = ProfileEntry { name: name, source: "user", file_path: path, installed: installed } e_count = e_count + 1 end if i = i + 1 end while // Pass 2: add vendor-dir *.yml files NOT already present (so user wins) mut j: int = 0 while j < vendor_count let fname: string = vendor_buf[j] if str.ends_with(fname, ".yml") let name: string = str.substring(fname, 0, str.length(fname) - 4) // Check if name already in entries_buf mut shadowed: bool = false mut k: int = 0 while k < e_count if entries_buf[k].name == name shadowed = true // Update the existing user entry's source label to indicate shadow entries_buf[k] = ProfileEntry { name: entries_buf[k].name, source: "user (shadows vendor)", file_path: entries_buf[k].file_path, installed: entries_buf[k].installed } end if k = k + 1 end while if not shadowed let path: string = paths.join(v_dir, fname) let installed_r = incus.profile_exists("default", name) mut installed: bool = false if rg.is_ok(installed_r) installed = rg.unwrap_ok(installed_r) end if entries_buf[e_count] = ProfileEntry { name: name, source: "vendor", file_path: path, installed: installed } e_count = e_count + 1 end if end if j = j + 1 end while // Compact to actual size mut entries: [ProfileEntry] = new [ProfileEntry](e_count) mut m: int = 0 while m < e_count entries[m] = entries_buf[m] m = m + 1 end while return entries end list_all fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string] // Resolve the file (user or vendor) let path_r = lookup(name, home_dir) if rg.is_err(path_r) return @Result[bool, string].Err(rg.unwrap_err(path_r)) end if let path: string = rg.unwrap_ok(path_r) // Read the file let yaml: string = iofile.readFile(path) if str.length(yaml) == 0 return @Result[bool, string].Err("empty or unreadable profile file: " + path) end if // Render — but if HOST_LAN_IP is needed and host.lan_ip is empty, fail with a hint if str.contains(yaml, "\${HOST_LAN_IP}") and str.length(host.lan_ip) == 0 return @Result[bool, string].Err("profile " + name + " requires \${HOST_LAN_IP} but [host].lan_ip is empty in registry. Run 'repoman setup' to detect and store the host LAN IP.") end if let rendered: string = render(yaml, host) // Apply via incus return incus.profile_create_or_edit("default", name, rendered) end install fn remove_profile(name: string): rg.Result[bool, string] return incus.delete_profile("default", name) end remove_profile fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string] let path_r = lookup(name, home_dir) if rg.is_err(path_r) return @Result[string, string].Err(rg.unwrap_err(path_r)) end if let path: string = rg.unwrap_ok(path_r) let yaml: string = iofile.readFile(path) let rendered: string = render(yaml, host) return @Result[string, string].Ok(rendered) end show fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string] // Render the file let path_r = lookup(name, home_dir) if rg.is_err(path_r) return @Result[string, string].Err(rg.unwrap_err(path_r)) end if let path: string = rg.unwrap_ok(path_r) let yaml: string = iofile.readFile(path) let rendered: string = render(yaml, host) // Fetch incus state let incus_r = incus.profile_get("default", name) if rg.is_err(incus_r) // Profile not installed — diff shows the rendered file as "would install" let msg: string = "profile " + name + " is not installed. Rendered would-be content:\n\n" + rendered return @Result[string, string].Ok(msg) end if let live: string = rg.unwrap_ok(incus_r) if rendered == live return @Result[string, string].Ok("(no drift)") end if // Simple side-by-side: file content marked '-- file --', incus content '-- incus --'. let header: string = "--- profile file (rendered): " + path + "\n+++ incus state\n" let body: string = "-- file --\n" + rendered + "\n-- incus --\n" + live return @Result[string, string].Ok(header + body) end diff end module /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2026, Leafscale, LLC - https://www.leafscale.com Project: repoman Filename: src/setup.reef Authors: Chris Tusa License: Description: First-time host bootstrap wizard (cmd_setup) ******************************************************************************/ module setup import core.str import core.result_generic as rg import io.console as console import io.dir as iodir import sys.env import sys.flag as flag import sys.process as p import incus import config import profile export type Environment fn detect_environment(home_dir: string): Environment fn detect_host_lan_ip(): string type Stage fn plan_stages(env: Environment): [Stage] fn apply_stage(stage: Stage, env: Environment, reg: config.Registry): rg.Result[config.Registry, string] fn cmd_setup(argv: [string]): int end export // Snapshot of the host state that `setup` cares about. type Environment = struct home_dir: string user: string host_lan_ip: string incus_reachable: bool repoman_project_present: bool end Environment // Stage is a planned action with a description for the user. type Stage = struct id: string description: string is_change: bool end Stage fn detect_environment(home_dir: string): Environment let user: string = env.get_env_or("USER", "") let lan_ip: string = detect_host_lan_ip() // incus reachable? let incus_pid: int = incus.process_run_silent("incus", ["version"]) mut incus_ok: bool = false if incus_pid >= 0 incus_ok = p.process_wait(incus_pid) == 0 end if // repoman project (only if incus is up) mut project_ok: bool = false if incus_ok let pe = incus.project_present("repoman") if rg.is_ok(pe) project_ok = rg.unwrap_ok(pe) end if end if return Environment { home_dir: home_dir, user: user, host_lan_ip: lan_ip, incus_reachable: incus_ok, repoman_project_present: project_ok } end detect_environment fn detect_host_lan_ip(): string let cap = incus.process_run_capture("ip", ["-4", "addr", "show", "br0"]) if cap.exit_code != 0 return "" end if // Look for the first occurrence of "inet " and grab the IPv4 token after it // until the next slash or space. `cap.stdout` is small, hand-roll the scan. let s: string = cap.stdout let n: int = str.length(s) let needle: string = "inet " let needle_len: int = 5 mut i: int = 0 while i + needle_len <= n if str.substring(s, i, needle_len) == needle let start: int = i + needle_len mut end_idx: int = start while end_idx < n and s[end_idx] != '/' and s[end_idx] != ' ' end_idx = end_idx + 1 end while return str.substring(s, start, end_idx - start) end if i = i + 1 end while return "" end detect_host_lan_ip fn plan_stages(env: Environment): [Stage] mut stages: [Stage] = new [Stage](2) stages[0] = Stage { id: "incus_project", description: "ensure Incus project 'repoman' exists", is_change: not env.repoman_project_present } stages[1] = Stage { id: "registry_defaults", description: "write registry defaults (schema 3, [host].lan_ip)", is_change: true } return stages end plan_stages fn apply_stage(stage: Stage, env: Environment, reg: config.Registry): rg.Result[config.Registry, string] if stage.id == "incus_project" if env.repoman_project_present return @Result[config.Registry, string].Ok(reg) end if let r = incus.project_ensure("repoman", false) if rg.is_err(r) return @Result[config.Registry, string].Err(rg.unwrap_err(r)) end if return @Result[config.Registry, string].Ok(reg) end if if stage.id == "registry_defaults" let new_host = config.Host { lan_ip: env.host_lan_ip } let new_reg = config.Registry { schema: 3, host: new_host, output: reg.output, defaults: reg.defaults, projects: reg.projects } // Ensure the user-profile-shadow dir exists so users can drop overrides // there without mkdir ceremony. iodir.create_dir_all is idempotent. let profiles_d: string = profile.user_dir(env.home_dir) let _md: bool = iodir.create_dir_all(profiles_d) return @Result[config.Registry, string].Ok(new_reg) end if return @Result[config.Registry, string].Err("unknown stage id: " + stage.id) end apply_stage fn cmd_setup(argv: [string]): int let parser: flag.FlagParser = flag.flag_parser_from(argv) flag.application(parser, "repoman setup") flag.description(parser, "First-time host bootstrap: incus project, registry, host LAN IP detection") let _f1 = flag.bool_flag(parser, "non-interactive", '\0', false, "accept all defaults without prompting") if not flag.parse(parser) console.printErr("repoman: error: " + flag.error(parser)) return 2 end if let non_interactive: bool = flag.get_bool(parser, "non-interactive") let home: string = env.get_env_or("HOME", "") if str.length(home) == 0 console.printErr("repoman: error: HOME is not set") return 3 end if let env_snap = detect_environment(home) if not env_snap.incus_reachable console.printErr("repoman: error: 'incus' is not installed or not on PATH") return 3 end if // Plan & display let stages = plan_stages(env_snap) println("") println("repoman setup plan:") let n: int = stages.length() mut i: int = 0 while i < n let st = stages[i] mut marker: string = " " if st.is_change marker = " * " end if println(marker + "[" + st.id + "] " + st.description) i = i + 1 end while println("") println("* = will change; otherwise no-op") println("") if not non_interactive let proceed = console.confirm("proceed?") if not proceed println("aborted") return 4 end if end if // Load registry (or init) let reg_r = config.load_or_init(home) if rg.is_err(reg_r) console.printErr("repoman: error: " + rg.unwrap_err(reg_r)) return 3 end if mut reg: config.Registry = rg.unwrap_ok(reg_r) // Apply stages mut k: int = 0 while k < n let st = stages[k] println("==> [" + st.id + "] " + st.description) let r = apply_stage(st, env_snap, reg) if rg.is_err(r) console.printErr("repoman: error: " + rg.unwrap_err(r)) return 1 end if reg = rg.unwrap_ok(r) k = k + 1 end while // Persist registry let cfg_path: string = config.registry_path(home) let saved = config.save(reg, cfg_path) if rg.is_err(saved) console.printErr("repoman: error: " + rg.unwrap_err(saved)) return 1 end if println("") println("setup complete.") println("") println(" next: repoman profile install --all (install vendor profile library)") println(" repoman new ") println(" repoman list") return 0 end cmd_setup end module /****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2026, Leafscale, LLC - https://www.leafscale.com Project: repoman Filename: src/sync.reef Authors: Chris Tusa License: Description: rsync-to-NFS backup driver (cmd_sync) with autofs/mount awareness ******************************************************************************/ module sync import core.str import core.result_generic as rg import sys.process as p import sys.fd as fd export fn build_rsync_args(src: string, dst: string, dry_run: bool, no_delete: bool, is_tty: bool, excluded_repos: [string]): [string] fn ensure_nfs_mounted(backup_root: string, verbose: bool): rg.Result[bool, string] fn run_rsync(args: [string]): int end export // Hardcoded excludes matching bash prototype. fn standard_excludes(): [string] return [ "node_modules/", "target/", "build/", "dist/", ".next/", "__pycache__/", "*.pyc", ".venv/", "venv/", ".cache/", ".tox/", ".pytest_cache/", ".mypy_cache/", ".ruff_cache/" ] end standard_excludes fn build_rsync_args(src: string, dst: string, dry_run: bool, no_delete: bool, is_tty: bool, excluded_repos: [string]): [string] let std: [string] = standard_excludes() let std_n: int = std.length() let ex_n: int = excluded_repos.length() // Capacity: 1 (-aHAX) + up to 3 info flags + 1 (--delete) + std_n excludes + ex_n excludes + 2 positionals let cap: int = 1 + 3 + 1 + std_n + ex_n + 2 mut buf: [string] = new [string](cap) mut k: int = 0 buf[k] = "-aHAX" k = k + 1 if dry_run buf[k] = "--dry-run" k = k + 1 buf[k] = "--itemize-changes" k = k + 1 buf[k] = "--info=stats2" k = k + 1 elif is_tty buf[k] = "--info=stats2,progress2" k = k + 1 else buf[k] = "--info=stats2" k = k + 1 end if if not no_delete buf[k] = "--delete" k = k + 1 end if mut i: int = 0 while i < std_n buf[k] = "--exclude=" + std[i] k = k + 1 i = i + 1 end while mut j: int = 0 while j < ex_n buf[k] = "--exclude=" + excluded_repos[j] + "/" k = k + 1 j = j + 1 end while buf[k] = src k = k + 1 buf[k] = dst k = k + 1 // Trim to actual size mut out: [string] = new [string](k) mut m: int = 0 while m < k out[m] = buf[m] m = m + 1 end while return out end build_rsync_args // Silent variant of process_run: child's stdout + stderr go to /dev/null. // Used for the NFS preflight probes where stat/findmnt would otherwise dump // file metadata and mount tables to the user's terminal on success, or // noisy "cannot stat" messages on failure (we provide our own diagnostic). fn process_run_silent(program: string, args: [string]): int let pid: int = p.process_fork() if pid < 0 return -1 end if if pid == 0 let dev_null: int = fd.fd_open("/dev/null", fd.O_WRONLY(), 0) if dev_null >= 0 let _o: int = fd.fd_dup2(dev_null, fd.STDOUT()) let _e: int = fd.fd_dup2(dev_null, fd.STDERR()) let _c: int = fd.fd_close(dev_null) end if let _x: int = p.process_run_exec(program, args) p.exit_now(127) end if return pid end process_run_silent // Helper: spawn `program args` either silent (quiet mode) or with stdio // passing through (verbose mode). Returns the child PID. fn spawn_probe(program: string, args: [string], verbose: bool): int if verbose return p.process_run(program, args) end if return process_run_silent(program, args) end spawn_probe fn ensure_nfs_mounted(backup_root: string, verbose: bool): rg.Result[bool, string] // Step 1: stat triggers autofs let pid1: int = spawn_probe("stat", [backup_root], verbose) if pid1 < 0 return @Result[bool, string].Err("cannot spawn stat") end if if p.process_wait(pid1) != 0 return @Result[bool, string].Err("cannot stat " + backup_root + " — autofs misconfigured or server unreachable") end if // Step 2: mountpoint let pid2: int = spawn_probe("mountpoint", ["-q", backup_root], verbose) if pid2 < 0 return @Result[bool, string].Err("cannot spawn mountpoint") end if if p.process_wait(pid2) != 0 return @Result[bool, string].Err(backup_root + " exists but is not a mount — NFS server unreachable?") end if // Step 3: findmnt -t nfs4 let pid3: int = spawn_probe("findmnt", ["-t", "nfs4", backup_root], verbose) if pid3 < 0 return @Result[bool, string].Err("cannot spawn findmnt") end if if p.process_wait(pid3) != 0 return @Result[bool, string].Err(backup_root + " is mounted but not as nfs4 — check /etc/auto.nfs") end if return @Result[bool, string].Ok(true) end ensure_nfs_mounted // Spawn rsync with the given argv. Inherits parent stdio (no capture). // Returns rsync's exit code, or -1 if spawn failed. // Named `run_rsync` because bare `run` collides with Reef's active-object // lifecycle keyword. fn run_rsync(args: [string]): int let pid: int = p.process_run("rsync", args) if pid < 0 return -1 end if return p.process_wait(pid) end run_rsync end module