module cli import core.str import core.result_generic as rg import core.convert as convert 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 config import incus import sync import paths export fn cmd_new(argv: [string]): int fn cmd_sync(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)") 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) // Reject duplicate name let pn: int = reg.projects.length() mut i: int = 0 while i < pn if reg.projects[i].name == name console.printErr("repoman: error: project '" + name + "' already in registry") console.printErr("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) console.printErr("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) console.printErr("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) // Ensure incus project console.printErr("==> incus project ensure " + reg.defaults.incus_project) let pe = incus.project_ensure(reg.defaults.incus_project) if rg.is_err(pe) console.printErr("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) if rg.is_err(ce) console.printErr("repoman: error: " + rg.unwrap_err(ce)) return 1 end if if rg.unwrap_ok(ce) console.printErr("repoman: error: container '" + name + "' already exists in project '" + reg.defaults.incus_project + "'") console.printErr("hint: incus delete --project " + reg.defaults.incus_project + " " + name) return 4 end if // Launch console.printErr("==> incus launch " + eff.image + " " + name) let lr = incus.launch(reg.defaults.incus_project, name, eff.image, eff.profiles) if rg.is_err(lr) console.printErr("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 console.printErr("==> incus device add " + name + " " + dev_name + " " + m.source + ":" + m.path) let dr = incus.device_add_disk(reg.defaults.incus_project, name, dev_name, m.source, m.path) if rg.is_err(dr) console.printErr("repoman: error: " + rg.unwrap_err(dr)) console.printErr("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) console.printErr("repoman: error: " + rg.unwrap_err(er)) return 1 end if e = e + 1 end while // Restart so binds + env take effect console.printErr("==> incus restart " + name) let rr = incus.restart(reg.defaults.incus_project, name) if rg.is_err(rr) console.printErr("repoman: error: " + rg.unwrap_err(rr)) return 1 end if // Build new project entry and write registry let new_p: config.Project = config.Project { name: name, repo: repo, image: eff.image, profiles: eff.profiles, created: "", last_sync: "", backup: true } let reg2_r = config.add_project(reg, new_p) if rg.is_err(reg2_r) console.printErr("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) console.printErr("repoman: error: " + rg.unwrap_err(saved)) return 1 end if // Ready hint — use $UID and $HOME for shell expansion (correct on any host) console.printErr("==> ready") console.printErr("") console.printErr(" shell in: incus exec --project " + reg.defaults.incus_project + " --user $UID --cwd " + repo_path + " --env HOME=$HOME " + name + " -- bash -l") console.printErr(" 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") 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) 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) if rg.is_err(mr) console.printErr("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 console.printErr("repoman: error: '" + name + "' not in registry") console.printErr("hint: repoman new " + name) return 1 end if let proj: config.Project = reg.projects[found] if not proj.backup console.printErr("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 console.printErr("==> rsync " + tags + src + " → " + dst) let exit_code: int = sync.run_rsync(rsync_args) if exit_code < 0 console.printErr("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 = "" // v0.1: timestamp blank; time stdlib integration is a follow-on 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 version_string(): string return "repoman 0.1.0" end version_string proc print_usage() console.printErr("Usage: repoman [args]") console.printErr("") console.printErr("Subcommands") console.printErr(" new [--repo ] [--image ]") console.printErr(" Launch a container in the 'repoman' Incus project; bind ~/repos/.") 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(" --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 == "new" return cmd_new(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