# repoman v0.1 — Design Spec

**Status:** v0.1 design, locked
**Date:** 2026-04-29
**Implementation language:** reef-lang 0.5.10
**Origin:** [VISION.md](../../../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`](../../reef-feedback.md) (response in [`docs/reef-feedback-response.md`](../../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_generic` — `Result[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`

```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]].profiles` — **effective** (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.

```toml
[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.image` → `defaults.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):
```bash
# 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:
```bash
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

```toml
[package]
name        = "repoman"
version     = "0.1.0"
author      = "Chris Tusa <chris.tusa@leafscale.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 build` → `build/repoman`
- `reefc clean`
- `reefc doc` → `docs/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`):

```make
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](../../../VISION.md) — design intent, audience, differentiators, open product questions.
- `~/.local/bin/repoman` — bash prototype (behavioral spec for `new` ≈ `create` 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.reef` — `process_spawn` / `process_exec` / wait/kill API used throughout.
- `~/reef-lang-0.5.10-source/reef-stdlib/encoding/toml.reef` — TOML codec for the registry.
