|
root / docs / superpowers / specs / 2026-04-29-repoman-v0.1-design.md
2026-04-29-repoman-v0.1-design.md markdown 467 lines 22.7 KB

repoman v0.1 — Design Spec

Status: v0.1 design, locked
Date: 2026-04-29
Implementation language: reef-lang 0.5.10
Origin: VISION.md (intent) + bash prototype at ~/.local/bin/repoman (behavioral spec for new/sync)
Outcome: the contract for the first reef build of repoman.


Reef version target

This spec targets reef 0.5.10, which shipped the stdlib additions requested in docs/reef-feedback.md (response in docs/reef-feedback-response.md). All four feedback sprints landed in a single 0.5.10 release on 2026-04-29:

  • io.path.expand_home, io.path.join (canonical synonym for join_path).
  • test.framework.assert_contains_string, assert_ok_int/_str/_bool, assert_err_int/_str/_bool.
  • encoding.toml.toml_parse_status, encoding.json.json_parse_status (truncation-aware parse).
  • io.file.rename, io.file.fsync (atomic-write primitives; parent-directory fsync still pending).
  • sys.flag.flag_parser_from(args) for sliced argv (subcommand dispatch).
  • core.result_genericResult[T, E] with is_ok, is_err, unwrap_ok, unwrap_err, unwrap_or. Construction uses explicit type args: @Result[int, string].Ok(42). Cross-module use unblocked by BUG-037 fix in the same release.
  • encoding.toml.TomlDoc + toml_parse_doc (struct-bundled parse output, replacing parallel-array threading).
  • encoding.toml.TomlBuilder and encoding.json.JsonBuilder (streaming serializers — closes the reader-without-writer asymmetry).

Deferred to 0.5.11 (does not affect repoman v0.1):

  • Cobra-style declarative subcommand_parser API. Phase 1 (flag_parser_from) is sufficient for repoman's subcommand dispatch — we hand-roll the outer dispatch on argv[1], then feed each subcommand a sliced argv.
  • expand_user("~user/..."). Current expand_home passes ~user/... through unchanged.

§2 (architecture's CLI parser hedge) and §3.4 (atomic-write fsync hedge) were originally written against 0.5.9 workarounds. They have been retargeted to the 0.5.10 APIs in rev 3.


1. Scope

v0.1 is "match-then-surpass": ship feature-parity with the bash prototype's two subcommands plus the one v0.1-only differentiator that's cheap to add now and expensive to retrofit (the Incus project namespace). Everything else from VISION (the remaining subcommands, REST integration, interactive wizard, JSON output mode) is deferred.

In scope:

  • repoman new <name> [--repo <dirname>] [--image <img>]
  • repoman sync [name] [--no-delete] [--dry-run]
  • repoman --version / repoman --help / repoman (no args → full help)
  • Central registry at ~/.config/repoman/repoman.toml
  • Per-project overrides at ~/.config/repoman/repos.d/<name>.toml
  • Incus project repoman as the namespace for every container repoman creates

Out of scope (deferred to v0.2 or later):

  • Subcommands: setup, list, shell, remove, status, update, config, adopt
  • Incus REST-over-unix-socket integration (v0.1 shells out to the incus CLI)
  • Interactive wizard / line-prompt mode for missing args
  • Auto-rollback when a new step fails midway
  • JSON output mode for automation
  • CI / public-forge release artifacts (prebuilt binaries)
  • Migration from the bash prototype's containers (which live in Incus's default project)
  • repoman config-by-env-var compatibility (LOCAL_REPOS/NFS_REPOS)
  • Incus profile management (e.g., repoman profile new dotfiles interactive create/edit). v0.1 expects users to author profiles directly via incus profile create/edit and reference them in [defaults].profiles. README documents the recommended dotfiles profile pattern (bind-mount ~/.hgrc/~/.gitconfig/etc.) for shared host config.
  • [defaults].seed_files (copy-at-create semantics). Bind-mount via Incus profile is sufficient for the v0.1 dotfiles use case; revisit if a real copy-on-create need surfaces.

2. Architecture

Six modules under src/, no nesting:

File Module Responsibility Pure?
src/main.reef (entry) proc main()cli.dispatch(sys.args.argv()) and exit.
src/cli.reef cli Subcommand routing, flag parsing, usage/version output. Returns exit codes. Hand-rolls outer dispatch on argv[1], then feeds each subcommand a sliced argv via sys.flag.flag_parser_from(args). Migrates to the declarative subcommand_parser API in 0.5.11+. mostly
src/config.reef config Registry types (Defaults, Project, Registry), per-project override (Override), EffectiveConfig. Load-or-init from disk (atomic write); merge defaults+override. yes
src/incus.reef incus Thin wrappers over the incus CLI via process_spawn(prog, argv)project_ensure, container_exists, launch, bind_repo, set_env, restart. Plus pure validate_name. wrappers thin; validate_name pure
src/sync.reef sync ensure_nfs_mounted (stat → mountpoint → findmnt -t nfs4 chain), pure build_rsync_args(opts), effectful run(opts) that spawns rsync and inherits stdio. arg builder pure
src/path.reef path expand_home, join, exists, is_dir. Thin wrappers over io.path / io.dir. yes

Boundaries. Pure logic (TOML schemas, name validation, rsync arg construction, path expansion, defaults merging) is unit-tested. Effectful wrappers (subprocess invocation, NFS mount checks, file write) are kept narrow so they can be smoke-tested via integration but don't need fakes. core.result_generic (Result[T, string]) carries errors out of every fallible function; main translates Result.Err into a stderr message + non-zero exit code. Construction syntax is @Result[T, string].Ok(value) / @Result[T, string].Err(msg) per 0.5.10 generics.

Subprocess discipline. Every external invocation goes through sys.process.process_spawn(program, argv) — never process_spawn_shell. This is non-negotiable: user-derived names (container, repo, paths) must not pass through a shell's word-splitting or globbing.

Dependency graph. main → cli → {config, incus, sync, path}. incus and sync are independent of each other. config depends only on encoding.toml, io.file, path. No cycles.

Concurrency. v0.1 is fully sequential. No Active Objects, no parallel sync. VISION's parallel-sync-across-N-projects idea is a v0.2 candidate.


3. Data shapes

3.1 Central registry — ~/.config/repoman/repoman.toml

[repoman]
schema = 1

[defaults]
repos_root     = "~/repos"
backup_root    = "/nfs/repos"
incus_project  = "repoman"
default_image  = "images:ubuntu/26.04/cloud"
profiles       = ["default", "claude-share"]

[[project]]
name        = "isurus"
repo        = "isurus-project"
image       = "images:ubuntu/26.04/cloud"
profiles    = ["default", "claude-share"]
created     = "2026-04-28T15:00:00Z"
last_sync   = ""           # "" = never synced
backup      = true         # false → sync skips

Fields:

  • [repoman].schema — registry schema version. v0.1 writes 1. Loader rejects any other value with a clear error and a hint to upgrade repoman.
  • [defaults].repos_root / backup_root~ expanded by path.expand_home at load time.
  • [[project]].repo — repo dirname relative to repos_root. Defaults to name if unspecified.
  • [[project]].image and [[project]].profileseffective (post-merge) values used at container-create time, snapshotted for the registry. If override repos.d/<name>.toml had [container].profiles, those (not defaults) get stored here. Stored explicitly so v0.2 list/status doesn't need to query Incus to display them.
  • [[project]].created / last_sync — ISO 8601 UTC strings. "" for never.
  • [[project]].backup — opt-out flag. Default true.

3.2 Per-project override — ~/.config/repoman/repos.d/<name>.toml

User-authored before repoman new. Optional. Read by new only.

[container]
image    = "images:debian/12/cloud"
profiles = ["default", "claude-share", "node-dev"]

[[mount]]
source = "~/.npm"          # host path, ~ expanded
path   = "/home/ctusa/.npm" # container path

[env]
NODE_ENV = "development"

Filename keys off the container name (<name>), not the repo dirname. So repoman new isurus --repo isurus-project reads repos.d/isurus.toml.

3.3 Merge semantics (new only)

Effective config priority:

Field Priority
image --image flag → override.container.imagedefaults.default_image
profiles override.container.profiles (replace, not merge) → defaults.profiles
mounts [auto repo bind] ++ override.[[mount]] (additive; the auto repo bind is always present)
env override.[env] (key-by-key; empty if absent)

Replace semantics for profiles is deliberate: VISION's example shows the override containing the full profiles list (including the defaults), and predictable replace beats subtle additive surprises.

3.4 Atomic write

Central registry is rewritten atomically on every change:

  1. io.file.writeFile("repoman.toml.tmp", contents) in the same directory as the target.
  2. io.file.fsync("repoman.toml.tmp") to push contents to disk before the rename.
  3. io.file.rename("repoman.toml.tmp", "repoman.toml").

Parent-directory fsync is not yet exposed (per the 0.5.10 changelog), so a power-loss window for the rename itself remains. Acceptable for a homelab dev tool; revisit when the runtime adds it.

TOML serialization is via encoding.toml.TomlBuilder from 0.5.10 — explicit toml_set_string / toml_set_string_array / toml_array_append_table for [[project]] entries, then toml_render to a string.

The override file is never written by repoman in v0.1. User authors it.

3.5 Validation on registry load

  • [repoman].schema == 1 — error otherwise.
  • All [[project]].name values pass incus.validate_name.
  • No duplicate name across [[project]] entries.
  • repos_root and backup_root resolve to non-empty strings after ~ expansion.

Failure → Result.Err with a clear message including the offending field. Exit code 3 (environment).


4. Subcommand flows

4.1 repoman new <name> [--repo <dirname>] [--image <img>]

  1. Parse args. name required; bad usage → exit 2.
  2. incus.validate_name(name) — pure check (alnum + hyphen, ≤63, no leading hyphen). Fail → exit 1.
  3. config.load_or_init() — read or create the registry with default [repoman]/[defaults].
  4. Reject if name already exists in [[project]] → exit 4 with hint.
  5. Resolve repo path: <defaults.repos_root>/<repo>. Error if directory doesn't exist (matches bash) → exit 3.
  6. Read ~/.config/repoman/repos.d/<name>.toml if present; parse into Override.
  7. Compute EffectiveConfig per the merge table in §3.3.
  8. incus.project_ensure(defaults.incus_project) — list, create if missing. Idempotent.
  9. incus.container_exists(project, name) — error if already present → exit 4 with incus delete --project <p> <name> hint.
  10. incus launch --project <p> --profile P1 --profile P2 ... <image> <name> (one process_spawn).
  11. For each effective mount (always: the auto repo bind at <repo_path>:<repo_path>; then any override mounts), incus config device add --project <p> <name> <devname> disk source=<src> path=<dst>. Device names: repo for the auto bind, mount-N (N=1..) for overrides.
  12. For each [env] entry, incus config set --project <p> <name> environment.KEY=VALUE.
  13. incus restart --project <p> <name> so binds and env take effect.
  14. Append a [[project]] entry with all snapshot fields; config.save(reg) atomically.
  15. Print "ready" + a correct shell-in hint using getuid() and $HOME (not hardcoded 1000//home/ctusa like bash).

Rollback policy. v0.1 does not auto-destroy on partial-failure. If launch succeeds but a downstream step fails (device-add, env-set, restart, registry write), surface the error and the exact incus delete --project <p> <name> command for manual cleanup. v0.2 candidate: --rollback-on-error.

4.2 repoman sync [name] [--no-delete] [--dry-run]

  1. Parse args.
  2. config.load_or_init().
  3. sync.ensure_nfs_mounted(defaults.backup_root) — three calls: stat <backup_root> (triggers autofs), mountpoint -q <backup_root>, findmnt -t nfs4 <backup_root>. Each via process_spawn. Any failure → exit 3 with a clear message identifying which step failed.
  4. Resolve sync target:
    • With name: find in registry. Missing → exit 1 with hint. backup = false → exit 1 (user explicitly asked, refusing is more honest than silent skip). src = <repos_root>/<repo>/, dst = <backup_root>/<repo>/.
    • Without name: src = <repos_root>/, dst = <backup_root>/. Build excludes from the standard list (see §4.3) plus <repo>/ for every project where backup = false.
  5. Pure sync.build_rsync_args(opts) produces argv. See §4.3 for the full set.
  6. Print one-line tag to stderr: ==> rsync (dry-run) (additive) <src> → <dst> (tags conditional on flags).
  7. process_spawn("rsync", argv). Inherit parent stdout/stderr — user sees rsync's progress live; we don't capture or reformat.
  8. If rsync exit 0: update last_sync to current ISO 8601 UTC and config.save(reg) atomically. "Affected" = the named project (single-project mode), or every project where backup != false (whole-tree mode).
  9. Exit code = rsync's exit code (passthrough). 0 = success; 23 = partial transfer; 24 = vanished files; etc.

4.3 rsync invocation

Base flags: -aHAX.

Adaptive info flags:

  • Dry-run: --dry-run --itemize-changes --info=stats2
  • Interactive (TTY on stdout): --info=stats2,progress2
  • Otherwise (cron / piped): --info=stats2

Delete: --delete unless --no-delete.

Excludes (hardcoded for v0.1 — matches bash prototype):

node_modules/
target/
build/
dist/
.next/
__pycache__/
*.pyc
.venv/
venv/
.cache/
.tox/
.pytest_cache/
.mypy_cache/
.ruff_cache/

Whole-tree mode appends --exclude=<repo>/ for every project where backup = false.

backup = false semantics:

  • Whole-tree sync: that repo is excluded by rsync; not synced; its last_sync is not touched.
  • Single-project sync: errors. The user explicitly named it.

5. CLI surface and error UX

5.1 Subcommands

repoman new <name> [--repo <dirname>] [--image <img>]
repoman sync [name] [--no-delete] [--dry-run]
repoman --version | -V
repoman --help    | -h | help
repoman                         # no args → print full help

5.2 Output discipline

  • All informational chatter (==> incus launch ..., ==> rsync (dry-run) ...) goes to stderr.
  • stdout is reserved for future machine-readable output. v0.1 produces no stdout (matches bash prototype, keeps cron logs clean).
  • Errors prefixed repoman: error: <message> to stderr. Append a hint: line where a clear next step exists.

5.3 Exit codes

Code Meaning
0 success
1 generic / unhandled error
2 bad usage (unknown subcommand, missing required arg, bad flag)
3 environment problem (NFS unreachable, repo dir missing, registry corrupt)
4 state conflict (container already exists, name already in registry)
(rsync codes) for sync: rsync's own exit code passes through when rsync itself fails. Pre-rsync errors use 1–4.

The rsync passthrough is deliberate: cron consumers can distinguish "couldn't even start" (3/4) from "rsync hit a problem" (rsync's own codes).

5.4 Error UX matrix

Class Example Code What the user sees
Bad usage repoman new 2 error msg + short usage hint
Validation invalid container name 1 error msg + offending value
State conflict name already in registry 4 error msg + cleanup hint
Environment NFS not mounted 3 "backup_root /nfs/repos is not mounted (autofs/server unreachable)"
Subprocess (incus) incus launch failed 1 our error msg + incus's stderr passed through
Subprocess (rsync) rsync mid-transfer error rsync's rsync's own stderr (we don't capture)

5.5 No interactive mode in v0.1

Missing required args produce a usage error, not a prompt. Wizard mode is v0.2.


6. Testing strategy

6.1 Unit-tested (pure logic) — full coverage expected

Module What's tested
path expand_home (~/, ~, no-op cases), join (trailing-slash handling)
incus validate_name — valid, leading-hyphen, too-long, dot/underscore, empty, exactly-63-char boundary
config TOML round-trip (parse → serialize → reparse equals original); schema rejection (unknown schema); load_or_init against a temp XDG_CONFIG_HOME; merge_with_defaults for image/profiles/mounts/env priority cases; add_project rejects duplicates; update_last_sync on known/unknown name
sync build_rsync_args covers every branch: dry-run on/off, delete on/off, TTY/non-TTY info flags, backup=false exclude generation, single-project vs whole-tree, every standard exclude present

6.2 Smoke-tested (effectful) — manual, not automated

  • incus.project_ensure, launch, bind_repo, restart — need a real Incus daemon.
  • sync.ensure_nfs_mounted, sync.run — need a real NFS mount and rsync.

Smoke test recipe (in README):

# In an existing repo dir under ~/repos:
repoman new test-foo
repoman sync test-foo --dry-run
incus delete --project repoman test-foo

Three commands, ~30 seconds, catches integration regressions before each release tag.

6.3 Test runner

Reef idiom: reefc run tests/test_<module>.reef per file, each a standalone program using test.framework's TestRunner. Documented loop in README:

for t in tests/test_*.reef; do
    echo "== $t =="
    reefc run "$t" || exit 1
done

6.4 Not tested in v0.1

  • main.reef — 5-line dispatcher, exercised by every invocation in dev.
  • CLI flag-parsing fine grain — exercised by manual usage during dogfooding; revisit in v0.2.
  • Exact rsync exit-code passthrough — trusts rsync.

6.5 No CI in v0.1

Public-forge + CI is post-v0.1 work. Bar for v0.1: the test loop runs cleanly locally.


7. Build, install, distribution

7.1 reef.toml manifest

[package]
name        = "repoman"
version     = "0.1.0"
author      = "Chris Tusa <christusa@gmail.com>"
description = "Per-project Incus containers + opinionated NFS/ZFS backup"
license     = "MIT"           # placeholder — open product question
url         = ""              # forge TBD

[build]
entry        = "src/main.reef"
output       = "repoman"
output_dir   = "build"
source_dirs  = ["src"]

[docs]
output           = "docs/api"
include_private  = false

7.2 Repo layout

~/repos/repoman/
├── reef.toml
├── Makefile             # 10-line install/uninstall wrapper for packagers
├── README.md            # quickstart + test loop + smoke-test recipe + recommended `dotfiles` Incus profile pattern
├── VISION.md            # design intent (stays at root)
├── .hgignore            # build/, *.o, etc.
├── docs/
│   ├── superpowers/specs/2026-04-29-repoman-v0.1-design.md  # this file
│   └── api/             # generated by `reefc doc`
├── src/
│   ├── main.reef
│   ├── cli.reef
│   ├── config.reef
│   ├── incus.reef
│   ├── sync.reef
│   └── path.reef
├── tests/
│   ├── test_path.reef
│   ├── test_config.reef
│   ├── test_incus.reef
│   └── test_sync.reef
└── build/               # generated; hg-ignored

7.3 Build / clean / docs

Reef-native:

  • reefc buildbuild/repoman
  • reefc clean
  • reefc docdocs/api/

7.4 Tests

Documented shell loop in README (§6.3); no wrapper.

7.5 Install

10-line Makefile, install/uninstall only (packagers expect DESTDIR/PREFIX):

PREFIX ?= /usr/local
DESTDIR ?=

build/repoman:
	reefc build

install: build/repoman
	install -d $(DESTDIR)$(PREFIX)/bin
	install -m 755 build/repoman $(DESTDIR)$(PREFIX)/bin/repoman

uninstall:
	rm -f $(DESTDIR)$(PREFIX)/bin/repoman

.PHONY: install uninstall

make install PREFIX=$HOME/.local and sudo make install both work; DESTDIR honored for fakeroot staging.

7.6 Single-binary distribution

Reef compiles to C → native. build/repoman statically links libreef_runtime.a. v0.1 doesn't use TLS (no REST yet) → libreef_tls.a not pulled in. Runtime deps: libc + the external incus, rsync, findmnt, mountpoint, stat binaries.

7.7 Release artifacts (post-v0.1)

Source tarball minimum; prebuilt binaries per platform once forge is chosen. Out of scope for v0.1.

7.8 v0.1 verification checklist

  1. reefc build succeeds clean.
  2. The test loop passes for every file in tests/.
  3. make install PREFIX=$HOME/.local (or sudo make install) puts repoman on PATH.
  4. Smoke test: repoman new test-foo (against an existing test repo) → repoman sync test-foo --dry-run → manual incus delete --project repoman test-foo. All exit 0.
  5. repoman --version prints 0.1.0.

8. Open product questions (do not block v0.1 dev)

  1. License. MIT placeholder in reef.toml. Final choice (MIT vs Apache-2.0) before public release.
  2. Forge. GitHub / Codeberg / self-hosted — affects release artifact pipeline.
  3. Env-var compatibility. Bash prototype reads LOCAL_REPOS / NFS_REPOS. Reef v0.1 does not — TOML registry is canonical. Decide before the bash version is retired whether to honor REPOMAN_REPOS_ROOT / REPOMAN_BACKUP_ROOT as overrides.
  4. Logging / observability. v0.1 = stderr only. JSON-lines mode is a v0.2 candidate.

9. Implementation pinned at scaffold-time (not now)

These are decisions deliberately deferred to the moment the code lands, because a 10-minute look at the actual reef API resolves them better than abstract debate:

  1. CLI parser. Hand-roll an argv[1] switch in cli.reef, then call sys.flag.flag_parser_from(argv[2..]) per subcommand. The surface to the rest of the code is cli.dispatch(argv: [string]): int. Migrate to subcommand_parser when 0.5.11 ships it.
  2. incus config show output parsing. Anywhere v0.1 needs to query Incus state (e.g., container_exists), prefer incus list --format csv or --format json over text scraping. Lock the format choice when implementing each call.

10. References

  • VISION.md — design intent, audience, differentiators, open product questions.
  • ~/.local/bin/repoman — bash prototype (behavioral spec for newcreate and sync).
  • ~/reef-lang-0.5.10-source/docs/language/reference/031_PROJECT_STRUCTURE.md — reef project conventions (reefc init, reef.toml, module-to-file mapping).
  • ~/reef-lang-0.5.10-source/reef-stdlib/sys/process.reefprocess_spawn / process_exec / wait/kill API used throughout.
  • ~/reef-lang-0.5.10-source/reef-stdlib/encoding/toml.reef — TOML codec for the registry.