/****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (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