Forráskód Böngészése

HA add-on + recipe repos (specs/tech-architecture/ha-addon-recipe-repos.md)

The 7-step plan: run sm121 inside Home Assistant as a Docker add-on with
launcher/recipe separation — recipes + config live in git recipe repos,
the launcher clones/pulls them, recipes are promoted across repos, and
repos are archived.

Step 1 — recipe repos (src/recipe_repos.rs):
- [[recipe_repos]] config (url/branch/token; first entry wins on a
  recipe-name collision)
- sync at every command start: clone/pull into
  ~/.cache/sm121/recipe-repos/<sha256(url)>; best-effort (unreachable =
  warn + skip, never blocks a serve; dry-run skips git I/O)
- repos.json registry (next to the global config; corrupt = .bak + fresh)
- CLI: sm121 repos {list,sync}, sm121 archive/unarchive <url>
- repo roots join recipe_sources (recipes/ dir) + docker_image_sources
  (repo root — build contexts)
- 10 hermetic file:// tests

Step 2 — promote (src/promote.rs):
- sm121 promote <recipe> --from <url> --to <url> [--apply]
- target add+commit+push FIRST, then source remove+commit+push (a
  failure duplicates the recipe, never deletes; collision refused)
- 5 tests with bare file:// remotes

Step 3 — registry image source (src/docker/registry_pull.rs):
- serve/build --image-source {repo,registry}: registry pulls the
  prebuilt {base}/{name}:{checksum12} on every launch node, tags it as
  the recipe's docker_image, verifies the sm121.checksum label
- 4 tests

Step 4 — the add-on image (docker-images/sm121-addon/):
- multi-stage Dockerfile (sm121 compiled in-container — arch-matching,
  no cross-toolchain) + entrypoint.sh (effective config from options,
  SSH key drop, repos sync, sm121 web on 3100 + re-sync supervisor)
- make addon-image / addon-push; scripts/smoke-addon.sh (passed:
  dashboard on 3100 + repo sync)
- sm121_config_extra: raw per-install TOML (credentials that must not
  live in the shared repo, e.g. [mikrotik] with its password)

Step 5 — sm121-ha-integration restructured (sibling repo): config.yaml
(HA manifest) + reference Dockerfile + README; the
custom_components/ prototype kept as reference (superseded).

Step 6 — scripts/seed-recipe-repos.sh: seed an extra recipe repo from
the launcher's tracked state (git archive). No config.toml is copied —
a repo's config.toml is operator-curated and CREDENTIAL-FREE (the repo
may be shared with other users); secrets come from the add-on options.
The live shared repo is git.kilork.org/sm121/recipes (restructured in
place 2026-10-11; the publish-bundle registry retired).

Step 7 — docs: AGENTS.md (HA add-on + recipe repos section, registry
retirement) + DEVELOPMENT.md (module layout).

Gates: 363 tests, clippy -D warnings, fmt, <=500 lines/file.
Alexander Korolev 1 hete
szülő
commit
65c1cfcdc8

+ 10 - 0
.dockerignore

@@ -0,0 +1,10 @@
+# Everything EXCEPT what the sm121-addon image build needs
+# (docker-images/sm121-addon/Dockerfile, context = repo root).
+# Keep src/ + locales/ + host-scripts/ + the cargo manifest + the entrypoint.
+*
+!Cargo.toml
+!Cargo.lock
+!src/
+!locales/
+!host-scripts/
+!docker-images/sm121-addon/entrypoint.sh

+ 4 - 0
.gitignore

@@ -16,3 +16,7 @@ docker-images/*/b12x-src.tar.gz
 
 # per-recipe run ledger (machine state, not source)
 recipes/*/.lock
+
+
+# docker build-context staging (addon smoke)
+.addon-context/

+ 71 - 2
AGENTS.md

@@ -18,6 +18,7 @@ See [`DEVELOPMENT.md`](DEVELOPMENT.md) for architecture, module layout, and dev
 
 ```
 serve/stop/bench/logs/health/doctor/exec/power/tmux/init-host-keys/config/web/report/runlock/validate
+repos/promote/archive/unarchive
 ```
 
 `validate` — offline recipe-contract gate (no cluster, no SSH): checks every
@@ -210,8 +211,14 @@ bench/smoke defaults that read the recipe port.**
 
 Recipes and their image build contexts (`docker-images/`) both live here
 and must move together — the served image must match the recipe revision.
-<https://git.kilork.org/sm121/recipes> holds only `sm121 publish` output
-(self-contained bundles) as the registry's storage.
+<https://git.kilork.org/sm121/recipes> is the **shared recipe repo** the
+add-on (and `[[recipe_repos]]`) syncs: source-layout `recipes/<name>/` +
+`docker-images/<dir>/` + a curated **credential-free** `config.toml`
+(the add-on's base config — the repo may be shared with other users, so
+no ssh key / HA token / mikrotik password in it). It **retired the
+old publish-bundle registry** (flat `sm121 publish` bundles as the
+registry's storage; `sm121recipes.onlinedb.org` is gone — the
+add-on recipe-repo flow supersedes it).
 
 ```toml
 engine = "vllm"                     # or "sglang"
@@ -739,6 +746,68 @@ once (`ssh <node> && docker login ghcr.io`); a failed push fails with the log
 tail + a `docker login` hint. `--no-push` (build + serve) skips the push even
 when `[registry]` is set; without the section, builds behave as before.
 
+## HA add-on + recipe repos
+
+Full design: [`specs/tech-architecture/ha-addon-recipe-repos.md`](specs/tech-architecture/ha-addon-recipe-repos.md).
+
+**Recipe repos** — `[[recipe_repos]]` in config (ordered list; **first entry
+wins on a recipe-name collision**). Each entry is a git repo holding
+`recipes/` (+ `docker-images/` build contexts); the launcher clones/pulls
+it into `~/.cache/sm121/recipe-repos/<sha256(url)>` at every command
+start (best-effort — an unreachable repo warns and is skipped, never
+blocks a serve of a recipe from a reachable repo; skipped in dry-run),
+then appends its `recipes/` dir to `recipe_sources` and its root to
+`docker_image_sources`. Archive state is a **separate JSON registry**
+(`repos.json` next to the global config), not a config flag:
+
+```
+sm121 repos                  # list: cloned? archived? last-pull ts? recipe count?
+sm121 repos sync [--force]   # clone/pull now (--force re-clones from scratch)
+sm121 archive <url>          # skip the repo in sync (state in repos.json)
+sm121 unarchive <url>
+sm121 promote <recipe> --from <url> --to <url> [--apply]
+                             # cross-repo git mv: target add+commit+push FIRST,
+                             # then source remove+commit+push (a failure
+                             # duplicates, never deletes; collision refused;
+                             # default/-d prints the exact git commands)
+```
+
+**Image source** — `serve`/`build` gain `--image-source {repo,registry}`
+(default `repo` = on-demand build from `docker-images/`). `registry` pulls
+the prebuilt `{base}/{name}:{checksum12}` from the `[registry]` base on
+every launch node, tags it as the recipe's `docker_image`, and verifies
+the `sm121.checksum` label — a missing tag names the tag + hints to
+build/push first (or fall back to `--image-source repo`). Mutually
+exclusive with `--build-node`.
+
+**The add-on image** (`docker-images/sm121-addon/`, multi-stage — sm121
+is compiled in-container so the binary matches the container arch):
+`make addon-image` → `sm121-addon:latest`; `make addon-push` →
+`ghcr.io/kilork/sm121-addon:latest`; smoke test `scripts/smoke-addon.sh`
+(entrypoint + `file://` recipe repo + `curl /api/state`). The entrypoint
+writes the effective config from the add-on options (env), drops the SSH
+key (base64 option), `sm121 repos sync`, then `sm121 web --port 3100
+--bind 0.0.0.0` (a bearer token is required) with a background re-sync
+loop (`SM121_REPO_RESYNC_MINUTES`). The HA add-on manifest
+(`config.yaml` + README) lives in the sibling **sm121-ha-integration**
+repo — its `custom_components/` custom-integration prototype is
+superseded (entities are a phase-2 nicety over the add-on's REST API).
+Per-install credentials that can't be flat options (e.g. a `[mikrotik]`
+block with its password) go in the `sm121_config_extra` option — raw
+TOML appended to the generated config, kept out of the log.
+
+**Single-launcher rule** — the per-recipe run ledger (`.lock` in the
+recipe repo) is the serialization point: a second launcher that serves
+the same recipe sees the open run and refuses. Run **one** launcher as
+the source of truth (the add-on); the Mac launcher is for dev (dry-runs,
+building, promote, bench campaigns). The live shared recipe repo is
+**`git.kilork.org/sm121/recipes`** (restructured 2026-10-11 to the spec
+layout; the publish-bundle registry retired with it) — extra repos
+(e.g. a playground) are seeded with
+`scripts/seed-recipe-repos.sh <name> [git-url]` (recipes/ +
+docker-images/ only; the config.toml stays operator-curated and
+credential-free).
+
 ## Power
 
 - `off`: SSH shutdown → wait → HA `turn_off` per-node entities + cluster extras

+ 54 - 1
DEVELOPMENT.md

@@ -30,7 +30,16 @@ src/
 ├── doctor.rs              # Doctor mode + --repair
 ├── pre_serve.rs           # PreServeCommand types
 ├── before_serve.rs        # Pre-serve hook execution
-└── health.rs              # Tmux health-check helpers
+├── health.rs              # Tmux health-check helpers
+├── recipe_repos.rs        # [[recipe_repos]] git sync (clone/pull into
+│                          #   ~/.cache/sm121/recipe-repos/<sha256(url)>),
+│                          #   the repos.json archive registry, sm121 repos/
+│                          #   archive/unarchive
+├── promote.rs             # sm121 promote (cross-repo git mv: target add +
+│                          #   push first, source remove + push second)
+└── docker/registry_pull.rs  # --image-source registry (pull the prebuilt
+                             #   {base}/{name}:{checksum} tag, verify the
+                             #   sm121.checksum label)
 ```
 
 ## Quality Gates
@@ -95,6 +104,50 @@ Auto-generated flags: `--host 0.0.0.0`, `--port <recipe.port>`, `--nnodes`, `--n
 
 Mozilla Fluent via `fluent-i18n` crate. Locale auto-detected from system. FTL files in `locales/{lang}/main.ftl`.
 
+### Recipe repos & the HA add-on
+
+Full design: `specs/tech-architecture/ha-addon-recipe-repos.md`.
+
+- **`[[recipe_repos]]`** (ordered list; first = highest priority on a
+  recipe-name collision): git repos holding `recipes/` (+ `docker-images/`),
+  cloned/pulled by `recipe_repos::sync_recipe_repos` into
+  `~/.cache/sm121/recipe-repos/<sha256(url)16>`. Best-effort: an
+  unreachable repo warns + is skipped (a dead playground must not take
+  down production serving). The synced roots are appended to
+  `recipe_sources` (as their `recipes/` dirs) and `docker_image_sources`
+  (as repo roots) in list order — the existing search-order semantics do
+  the shadowing. Skipped in dry-run (no git I/O).
+- **Archive registry** (`repos.json` next to the global config): separate
+  from the config, so a repo can be archived without editing it
+  (`sm121 archive <url>` / `unarchive`). Missing → fresh; corrupt →
+  `.bak` + fresh + warning.
+- **`sm121 promote <recipe> --from <url> --to <url> [--apply]`**:
+  decomposed git mv — target checkout: `cp -a` + `git add/commit/push`
+  FIRST; then source: `git rm -r` + commit + push. A failure in between
+  leaves the recipe duplicated (safe under priority semantics), never
+  deleted. Collision in the target is refused up front. `--dry-run` (the
+  global `-d`) prints the exact commands.
+- **`--image-source {repo,registry}`** (serve/build): `repo` (default) =
+  the current on-demand build from `docker-images/`; `registry` pulls
+  the prebuilt `{base}/{name}:{checksum12}` from the `[registry]` base on
+  every launch node, tags it as the recipe's `docker_image`, and
+  verifies the `sm121.checksum` label (a missing tag names the tag +
+  points at build/push or the `repo` fallback). Mutually exclusive with
+  `--build-node`.
+- **The add-on image** (`docker-images/sm121-addon/`): multi-stage
+  (sm121 is compiled in-container — the binary always matches the
+  container arch), debian-slim + git + jq, entrypoint writes the
+  effective config from env (the HA add-on options), drops the SSH key
+  (base64 option), `sm121 repos sync`, then `sm121 web --port 3100
+  --bind 0.0.0.0` with a re-sync supervisor. `make addon-image` /
+  `addon-push`; smoke: `scripts/smoke-addon.sh`. The HA add-on manifest
+  lives in the sibling `sm121-ha-integration/` repo (its
+  `custom_components/` prototype is superseded — entities are a phase-2
+  nicety over the add-on's REST API).
+- **Single-launcher rule**: the per-recipe run ledger (`.lock` in the
+  recipe repo) serializes serving — run ONE launcher as the source of
+  truth (the add-on); the Mac launcher is for dev.
+
 ### Module size limit
 
 Every `src/**/*.rs` file must stay ≤500 lines. Enforced by `make check-lines`.

+ 14 - 1
Makefile

@@ -1,4 +1,4 @@
-.PHONY: check check-fmt check-test check-clippy check-lines fmt clippy test help
+.PHONY: check check-fmt check-test check-clippy check-lines fmt clippy test addon-image addon-push help
 
 # Run all quality gates (delegates to the shared script for use in CI).
 check:
@@ -36,6 +36,19 @@ check-lines:
 fmt:
 	cargo fmt --all
 
+# The sm121 HA add-on image (docker-images/sm121-addon/): multi-stage
+# docker build from the repo root (src/ compiled in-container — the binary
+# always matches the container's arch; .dockerignore trims the context).
+# Smoke test: scripts/smoke-addon.sh.
+addon-image:
+	docker build -t sm121-addon:latest -f docker-images/sm121-addon/Dockerfile .
+
+# Push the add-on image to the [registry] base (the HA add-on's config.yaml
+# references it). Requires `docker login ghcr.io`.
+addon-push:
+	docker tag sm121-addon:latest ghcr.io/kilork/sm121-addon:latest
+	docker push ghcr.io/kilork/sm121-addon:latest
+
 # Auto-fix lints (clippy --fix); review the diff before committing.
 clippy-fix:
 	cargo clippy --all-targets --fix --allow-dirty --allow-staged

+ 36 - 0
docker-images/sm121-addon/Dockerfile

@@ -0,0 +1,36 @@
+# sm121 HA add-on image — the launcher as a 24/7 Home Assistant add-on
+# (specs/tech-architecture/ha-addon-recipe-repos.md).
+#
+# Multi-stage: the launcher is COMPILED IN-CONTAINER (stage 1) so the
+# binary always matches the container's architecture (no cross-toolchain
+# on the host). Build context = the repo root (trimmed by .dockerignore
+# to src/ + locales/ + host-scripts/ + the cargo manifest).
+#
+#   make addon-image
+#   scripts/smoke-addon.sh     # entrypoint + file:// repo + curl /api/state
+
+# --- stage 1: compile ----------------------------------------------------------
+# (matches the repo toolchain — edition 2024 + let-chains need rust ≥ 1.88)
+FROM rust:1.98-bookworm AS build
+WORKDIR /src
+COPY Cargo.toml Cargo.lock ./
+COPY src/ src/
+COPY locales/ locales/
+COPY host-scripts/ host-scripts/
+RUN cargo build --release
+
+# --- stage 2: the add-on -------------------------------------------------------
+FROM debian:bookworm-slim
+
+# git (recipe-repo sync) + jq (env → config generation) + ca-certs (https)
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends git ca-certificates jq \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=build /src/target/release/sm121 /usr/local/bin/sm121
+COPY docker-images/sm121-addon/entrypoint.sh /entrypoint.sh
+RUN chmod 755 /usr/local/bin/sm121 /entrypoint.sh
+
+ENV SM121_WEB_PORT=3100
+EXPOSE 3100
+ENTRYPOINT ["/entrypoint.sh"]

+ 106 - 0
docker-images/sm121-addon/entrypoint.sh

@@ -0,0 +1,106 @@
+#!/bin/sh
+# sm121 HA add-on entrypoint (specs/tech-architecture/ha-addon-recipe-repos.md):
+#
+#   1. write the effective /etc/sm121/config.toml from the add-on options
+#      (env vars); an optional base config at /etc/sm121/base.toml (e.g. a
+#      recipe repo's config.toml, mounted) carries the cluster definitions
+#      — the generated sections (recipe_repos / home_assistant / registry)
+#      are APPENDED, so the base must not define them
+#   2. drop the SSH key (a mounted secret) at SM121_SSH_KEY_PATH
+#   3. sync the recipe repos (clone/pull)
+#   4. run `sm121 web` on 3100; a supervisor loop re-syncs the repos every
+#      SM121_REPO_RESYNC_MINUTES so new recipes land without a restart
+#
+# Env (the add-on options — HA maps each `sm121_*` option to the
+# uppercased env var; the standalone flow sets the same names directly):
+#   SM121_RECIPE_REPOS        JSON list of {"url","token"} → [[recipe_repos]]
+#   SM121_SSH_USER            ssh_user
+#   SM121_SSH_KEY_B64         base64 private key (HA option; written to KEY_PATH)
+#   SM121_SSH_KEY             path to a mounted private key (docker secret mount)
+#   SM121_SSH_KEY_PATH        where the key lands (default /etc/sm121/ssh_key)
+#   SM121_HA_URL / SM121_HA_TOKEN   → [home_assistant]
+#   SM121_REGISTRY_BASE       → [registry] base (for --image-source registry)
+#   SM121_CONFIG_EXTRA        raw TOML appended verbatim (per-install
+#                             credentials that must not live in the shared
+#                             repo, e.g. a [mikrotik] block with its password)
+#   SM121_REPO_RESYNC_MINUTES background re-sync interval (default 10)
+#   SM121_WEB_PORT            dashboard port (default 3100)
+#   SM121_WEB_BIND            dashboard bind (default 0.0.0.0)
+#   SM121_WEB_TOKEN           bearer token (REQUIRED for a non-loopback bind)
+set -eu
+
+CONFIG=/etc/sm121/config.toml
+mkdir -p /etc/sm121
+
+# --- 1. the effective config --------------------------------------------------
+if [ -f /etc/sm121/base.toml ]; then
+  cp /etc/sm121/base.toml "$CONFIG"
+else
+  : > "$CONFIG"
+fi
+
+{
+  # (if-form, not `&&`: a false condition must not trip `set -e`)
+  if [ -n "${SM121_SSH_USER:-}" ]; then printf 'ssh_user = "%s"\n' "$SM121_SSH_USER"; fi
+  if [ -n "${SM121_SSH_KEY_PATH:-}" ]; then printf 'ssh_key_path = "%s"\n' "$SM121_SSH_KEY_PATH"; fi
+} >> "$CONFIG"
+
+# JSON list of {"url","token"} → [[recipe_repos]] blocks (tojson keeps the
+# values properly escaped — JSON basic strings ARE valid TOML strings).
+if [ -n "${SM121_RECIPE_REPOS:-}" ]; then
+  printf '%s' "$SM121_RECIPE_REPOS" | jq -r '
+    .[] | ("[[recipe_repos]]\nurl = " + (.url | tojson) + "\n"
+           + (if .token then "token = " + (.token | tojson) + "\n" else "" end))
+  ' >> "$CONFIG"
+fi
+
+if [ -n "${SM121_HA_URL:-}" ] && [ -n "${SM121_HA_TOKEN:-}" ]; then
+  printf '[home_assistant]\nurl = "%s"\ntoken = "%s"\n' \
+    "$SM121_HA_URL" "$SM121_HA_TOKEN" >> "$CONFIG"
+fi
+if [ -n "${SM121_REGISTRY_BASE:-}" ]; then
+  printf '[registry]\nbase = "%s"\n' "$SM121_REGISTRY_BASE" >> "$CONFIG"
+fi
+
+# Raw per-install TOML, appended verbatim (the option is a multi-line
+# string). This is where credentials that can't be expressed as flat
+# options live — e.g. a [mikrotik] block (it carries a password, so it is
+# deliberately NOT in the shared recipe repo's config.toml). Never logged.
+if [ -n "${SM121_CONFIG_EXTRA:-}" ]; then
+  printf '%s\n' "$SM121_CONFIG_EXTRA" >> "$CONFIG"
+fi
+
+# --- 2. the SSH key ------------------------------------------------------------
+# HA add-on options cannot mount files: the key arrives base64-encoded as
+# SM121_SSH_KEY_B64 (the `sm121_ssh_key_b64` option) and is written to
+# SM121_SSH_KEY_PATH (default /etc/sm121/ssh_key). A plain file path
+# (SM121_SSH_KEY, e.g. a docker secret mount) is also accepted.
+KEY_PATH="${SM121_SSH_KEY_PATH:-/etc/sm121/ssh_key}"
+if [ -n "${SM121_SSH_KEY_B64:-}" ]; then
+  mkdir -p "$(dirname "$KEY_PATH")"
+  printf '%s' "$SM121_SSH_KEY_B64" | base64 -d > "$KEY_PATH"
+  chmod 600 "$KEY_PATH"
+elif [ -n "${SM121_SSH_KEY:-}" ] && [ -f "$SM121_SSH_KEY" ]; then
+  cp "$SM121_SSH_KEY" "$KEY_PATH"
+  chmod 600 "$KEY_PATH"
+fi
+
+# --- 3 + 4. sync + web, with the re-sync supervisor ----------------------------
+SM121="sm121 --config $CONFIG"
+$SM121 repos sync || echo "warning: initial repo sync failed (the supervisor retries)"
+
+INTERVAL="${SM121_REPO_RESYNC_MINUTES:-10}"
+(
+  while :; do
+    sleep "$((INTERVAL * 60))"
+    $SM121 repos sync || true
+  done
+) &
+
+# A web crash exits the container; the HA restart policy (always) re-runs
+# this entrypoint (re-sync + fresh dashboard). No state is lost (the run
+# ledger lives in the recipe repos, the cache in ~/.cache/sm121).
+exec $SM121 web \
+  --port "${SM121_WEB_PORT:-3100}" \
+  --bind "${SM121_WEB_BIND:-0.0.0.0}" \
+  ${SM121_WEB_TOKEN:+--token "$SM121_WEB_TOKEN"}

+ 106 - 0
scripts/seed-recipe-repos.sh

@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+# Seed a git recipe repo (recipes-production / recipes-playground) from
+# the current launcher repo state (specs/tech-architecture/
+# ha-addon-recipe-repos.md, step 6).
+#
+# The operator creates the bare repo on git.kilork.org FIRST; this script
+# builds the layout, commits it, and prints the push command:
+#
+#   scripts/seed-recipe-repos.sh <name> [git-url]
+#
+# <name>     the repo name (recipes-production | recipes-playground)
+# git-url    the git URL of the repo the operator created (printed in the
+#            push instructions; a local path also works for a dry run)
+#
+# Layout (a subset of the launcher repo, per the spec):
+#   recipes/<name>/…     # tracked recipe dirs (machine state like .lock is
+#                        # gitignored → excluded by `git archive` for free)
+#   docker-images/<dir>/ # build contexts (source of truth; optional when
+#                        # the image is prebuilt in the registry)
+#
+# NOTE: no config.toml is copied. A recipe repo's config.toml is
+# operator-curated and must be CREDENTIAL-FREE (the repo may be shared
+# with other users): clusters/nccl only. Secrets (ssh user/key, HA
+# url/token, mikrotik password, registry base) come from the add-on
+# options, never from the repo — see the curated example in
+# git.kilork.org/sm121/recipes.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+NAME="${1:-}"
+URL="${2:-}"
+if [ -z "$NAME" ]; then
+  echo "usage: scripts/seed-recipe-repos.sh <name> [git-url]" >&2
+  echo "  e.g. scripts/seed-recipe-repos.sh recipes-production https://git.kilork.org/sm121/recipes-production.git" >&2
+  exit 1
+fi
+
+if ! git rev-parse --git-dir >/dev/null 2>&1; then
+  echo "error: run this from the sm121-launcher repo (it reads the tracked state via git archive)" >&2
+  exit 1
+fi
+
+PARENT="$(dirname "$(pwd)")"
+OUT="$PARENT/$NAME"
+if [ -e "$OUT" ]; then
+  echo "error: $OUT already exists — refusing to clobber" >&2
+  exit 1
+fi
+
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT
+
+# The tracked state of the launcher repo (gitignored machine state —
+# .lock ledgers, target/, scratch — is excluded for free).
+git archive --prefix=repo/ HEAD | tar -x -C "$TMP"
+
+mkdir -p "$OUT"
+# NOTE: no config.toml is seeded — it is operator-curated and must stay
+# credential-free (the repo may be shared with other users). The launcher
+# repo's config.toml carries secrets (HA token, mikrotik password, ssh
+# key path) and must never be copied into a shared repo.
+[ -d "$TMP/repo/recipes" ] && cp -R "$TMP/repo/recipes" "$OUT/recipes"
+[ -d "$TMP/repo/docker-images" ] && cp -R "$TMP/repo/docker-images" "$OUT/docker-images"
+
+# A minimal README so the repo documents its own layout.
+cat > "$OUT/README.md" <<EOF
+# sm121 $NAME
+
+A sm121 recipe repo (specs/tech-architecture/ha-addon-recipe-repos.md in
+the launcher repo). Layout:
+
+- `recipes/<name>/recipe.toml` — the recipes
+- `docker-images/<dir>/` — image build contexts (source of truth;
+  optional when the image is prebuilt in the registry)
+- `config.toml` (operator-curated, NOT seeded by the script) — the shared
+  cluster/config source of truth. CREDENTIAL-FREE by contract (this repo
+  may be shared with other users): clusters/nccl only; the secrets (ssh
+  user/key, HA url/token, mikrotik password, registry base) come from the
+  add-on options (sm121_ssh_user, sm121_ssh_key_b64, sm121_ha_url,
+  sm121_ha_token, sm121_registry_base, sm121_config_extra)
+
+Seeded from the sm121-launcher repo on $(date -u +%Y-%m-%d).
+EOF
+
+git -C "$OUT" init -q
+git -C "$OUT" add -A
+git -C "$OUT" commit -qm "seed $NAME from sm121-launcher @ $(git rev-parse --short HEAD)"
+
+N_RECIPES=$(find "$OUT/recipes" -name recipe.toml 2>/dev/null | wc -l | tr -d ' ')
+N_IMAGES=$(find "$OUT/docker-images" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')
+echo "seeded $OUT:"
+echo "  recipes:       $N_RECIPES"
+echo "  docker-images: $N_IMAGES dirs"
+echo
+echo "push it (the operator creates the repo first):"
+if [ -n "$URL" ]; then
+  echo "  git -C $OUT remote add origin $URL"
+  echo "  git -C $OUT push -u origin $(git -C "$OUT" symbolic-ref --short HEAD)"
+else
+  echo "  git -C $OUT remote add origin <the git.kilork.org URL>"
+  echo "  git -C $OUT push -u origin main"
+fi
+echo
+echo "then add it to the launcher config (order = priority, first wins):"
+echo "  [[recipe_repos]]"
+echo "  url = \"${URL:-<git-url>}\""

+ 56 - 0
scripts/smoke-addon.sh

@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# Smoke test for the sm121 add-on image (specs/tech-architecture/
+# ha-addon-recipe-repos.md, step 3): run the entrypoint with a `file://`
+# recipe repo (mounted) + a token, and assert the dashboard answers on
+# 3100 (curl /api/state).
+#
+#   scripts/smoke-addon.sh [image]     (default sm121-addon:latest)
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+IMG="${1:-sm121-addon:latest}"
+TOKEN=smoke-test
+
+# A minimal `file://` recipe repo, mounted read-only at /repos. It lives
+# under target/ (gitignored) so it sits on a volume the docker VM shares
+# — colima only mounts /Volumes here; a $HOME tmpdir would mount empty.
+REPO="$(pwd)/target/smoke-addon-repo"
+rm -rf "$REPO"; mkdir -p "$REPO"
+git init -q "$REPO"
+mkdir -p "$REPO/recipes/demo"
+echo 'engine = "vllm"' > "$REPO/recipes/demo/recipe.toml"
+git -C "$REPO" add -A
+git -C "$REPO" -c user.email=t@sm121.test -c user.name=sm121-test commit -qm init
+
+CID="$(docker run -d \
+  -p 3100:3100 \
+  -v "$REPO":/repos:ro \
+  -e SM121_RECIPE_REPOS='[{"url":"file:///repos"}]' \
+  -e SM121_SSH_USER=u \
+  -e SM121_WEB_TOKEN="$TOKEN" \
+  "$IMG")"
+trap 'docker rm -f "$CID" >/dev/null 2>&1 || true; rm -rf "$REPO"' EXIT
+
+# Wait for the dashboard (up to 30 s — the first sync is a clone).
+ok=""
+for _ in $(seq 1 30); do
+  if curl -sf -H "Authorization: Bearer $TOKEN" \
+      http://127.0.0.1:3100/api/state >/dev/null 2>&1; then
+    ok=1
+    break
+  fi
+  sleep 1
+done
+
+echo "--- container log (tail) ---"
+docker logs "$CID" 2>&1 | tail -5
+
+if [ -n "$ok" ]; then
+  echo "--- /api/state (first 200 chars) ---"
+  curl -sf -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3100/api/state | head -c 200
+  echo
+  echo "smoke OK: the add-on dashboard answered on 3100"
+else
+  echo "smoke FAILED: no answer on 3100" >&2
+  exit 1
+fi

+ 1 - 0
src/after_stop.rs

@@ -179,6 +179,7 @@ mod tests {
             drop_caches: true,
             mount_aliases: std::collections::HashMap::new(),
             recipe_sources: Vec::new(),
+            recipe_repos: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
             registry: None,

+ 1 - 0
src/before_serve.rs

@@ -230,6 +230,7 @@ mod tests {
             drop_caches: true,
             mount_aliases: HashMap::new(),
             recipe_sources: Vec::new(),
+            recipe_repos: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
             registry: None,

+ 50 - 1
src/cli.rs

@@ -2,7 +2,8 @@ use crate::runlock::RunlockCommands;
 use clap::{Parser, Subcommand, ValueEnum};
 
 pub use crate::cli_sub::{
-    ConfigCommands, PowerAction, PublishArgs, RegistryCommands, SyncImageArgs, TmuxCommands,
+    ConfigCommands, PowerAction, PublishArgs, RegistryCommands, ReposArgs, ReposCommands,
+    SyncImageArgs, TmuxCommands,
 };
 
 /// Output format for human-readable reports (`--format`).
@@ -121,6 +122,12 @@ pub enum Commands {
         /// is configured and the image needs (re)building).
         #[arg(long)]
         no_push: bool,
+
+        /// Where the image comes from: build from `docker-images/` (default)
+        /// or pull the prebuilt `{base}/{name}:{checksum}` from `[registry]`
+        /// (skips the build; the image must already be pushed).
+        #[arg(long, value_enum, default_value_t = crate::docker::ImageSource::Repo)]
+        image_source: crate::docker::ImageSource,
     },
 
     /// Show container logs
@@ -274,6 +281,43 @@ pub enum Commands {
     #[command(subcommand)]
     Runlock(RunlockCommands),
 
+    /// Manage the configured git recipe repos (clone/pull cache, archive
+    /// registry). `sm121 repos` lists state; `sm121 repos sync [--force]`
+    /// re-pulls.
+    Repos(ReposArgs),
+
+    /// Archive a recipe repo (it is skipped by sync; state in repos.json)
+    Archive {
+        /// The repo URL (a `[[recipe_repos]]` entry)
+        url: String,
+    },
+
+    /// Un-archive a recipe repo (it takes part in sync again)
+    Unarchive {
+        /// The repo URL (a `[[recipe_repos]]` entry)
+        url: String,
+    },
+
+    /// Move a recipe across recipe repos (git mv + commit + push; the
+    /// target is updated first, so a failure duplicates, never deletes).
+    /// Default (or `-d`) prints the exact commands; `--apply` executes.
+    Promote {
+        /// Recipe name (its directory under the repo's `recipes/`)
+        recipe: String,
+
+        /// Source repo URL (a `[[recipe_repos]]` entry)
+        #[arg(long, required = true)]
+        from: String,
+
+        /// Target repo URL (a `[[recipe_repos]]` entry)
+        #[arg(long, required = true)]
+        to: String,
+
+        /// Execute the move (default: print the plan only)
+        #[arg(long)]
+        apply: bool,
+    },
+
     /// Launch the web dashboard (axum + SSE) on a local port
     Web {
         #[arg(long, default_value_t = 3100)]
@@ -329,6 +373,11 @@ pub enum Commands {
         /// is configured).
         #[arg(long)]
         no_push: bool,
+
+        /// Where the image comes from: build from `docker-images/` (default)
+        /// or pull the prebuilt tag from `[registry]` (no build, no push).
+        #[arg(long, value_enum, default_value_t = crate::docker::ImageSource::Repo)]
+        image_source: crate::docker::ImageSource,
     },
 
     /// Power on/off nodes via Home Assistant (smart plugs / Wake-on-LAN).

+ 21 - 0
src/cli_sub.rs

@@ -151,6 +151,27 @@ pub enum TmuxCommands {
     },
 }
 
+/// `sm121 repos` — the configured git recipe repos. `sm121 repos` (no
+/// subcommand) lists state; `sm121 repos sync` clones/pulls them now.
+#[derive(clap::Args)]
+pub struct ReposArgs {
+    #[command(subcommand)]
+    pub command: Option<ReposCommands>,
+}
+
+#[derive(Clone, clap::Subcommand)]
+pub enum ReposCommands {
+    /// List the configured repos with their state (cloned? archived?
+    /// last-pull ts? recipe count?)
+    List,
+    /// Clone/pull the configured recipe repos now (`--force` re-clones
+    /// each cached checkout from scratch)
+    Sync {
+        #[arg(long)]
+        force: bool,
+    },
+}
+
 #[derive(Args)]
 pub struct SyncImageArgs {
     #[arg(long, required = true)]

+ 5 - 0
src/cluster_state/tests.rs

@@ -211,6 +211,7 @@ fn select_smallest_with_enough_free() {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        recipe_repos: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         registry: None,
@@ -243,6 +244,7 @@ fn select_cluster_not_found_errors() {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        recipe_repos: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         registry: None,
@@ -279,6 +281,7 @@ fn no_cluster_has_enough_free_errors() {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        recipe_repos: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         registry: None,
@@ -311,6 +314,7 @@ fn single_node_allows_cluster_without_nccl() {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        recipe_repos: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         registry: None,
@@ -353,6 +357,7 @@ fn multi_node_requires_nccl() {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        recipe_repos: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         registry: None,

+ 1 - 0
src/config/tests.rs

@@ -44,6 +44,7 @@ fn tcfg() -> Config {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        recipe_repos: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         registry: None,

+ 21 - 0
src/config/types.rs

@@ -85,6 +85,13 @@ pub struct Config {
     /// Extra recipe dirs (each `<name>/recipe.toml`) searched after `./recipes` (`~` expanded).
     #[serde(default)]
     pub recipe_sources: Vec<String>,
+    /// Ordered git recipe repos (first entry = highest priority on a
+    /// recipe-name collision). Cloned/pulled into a local cache by
+    /// `crate::recipe_repos` and appended to the recipe search path in the
+    /// same order; archive state lives in the separate `repos.json`
+    /// registry, not here.
+    #[serde(default)]
+    pub recipe_repos: Vec<RecipeRepo>,
     /// Extra image-build dirs searched after `./docker-images`: repo roots
     /// (with `docker-images/`) or dirs of image dirs; `~` expanded.
     #[serde(default)]
@@ -107,6 +114,20 @@ pub struct RegistryConfig {
     pub base: String,
 }
 
+/// One `[[recipe_repos]]` entry: a git repo holding `recipes/` (and
+/// `docker-images/` build contexts), cloned/pulled by the launcher into a
+/// local cache and merged into the recipe set. List order is priority:
+/// a recipe name present in an earlier repo shadows the later one.
+#[derive(Clone, Debug, Deserialize)]
+pub struct RecipeRepo {
+    /// Git URL (https/ssh/`file://`).
+    pub url: String,
+    /// Optional private-repo credential (deploy key / PAT); embedded in the
+    /// clone URL only inside the local cache, never committed.
+    #[serde(default)]
+    pub token: Option<String>,
+}
+
 #[derive(Clone, Deserialize)]
 pub struct HomeAssistantConfig {
     pub url: String,

+ 1 - 0
src/detect.rs

@@ -157,6 +157,7 @@ mod tests {
             drop_caches: false,
             mount_aliases: Default::default(),
             recipe_sources: Vec::new(),
+            recipe_repos: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
             registry: None,

+ 86 - 16
src/docker/build.rs

@@ -4,7 +4,7 @@ use super::push;
 use super::shq;
 use crate::config::{Cluster, Config, Node, Recipe};
 use crate::ssh::run_ssh;
-use anyhow::Result;
+use anyhow::{Context, Result};
 use base64::Engine;
 use base64::engine::general_purpose::STANDARD;
 use std::path::Path;
@@ -188,6 +188,32 @@ pub async fn handle_build(
     build_node_name: &str,
 ) -> Result<bool> {
     if opts.dry_run {
+        if opts.image_source == super::ImageSource::Registry {
+            if let Some(reg) = config.registry.as_ref()
+                && let dir = recipe.docker_image_dir.as_deref().unwrap_or(recipe_name)
+                && let Ok((_, checksum)) = super::build_dir::build_checksum(
+                    &std::path::Path::new("docker-images").join(dir),
+                    recipe.build_profile.as_deref(),
+                    &recipe.build.versions,
+                )
+                && let Some((version_ref, _)) = push::registry_refs(
+                    &reg.base,
+                    &recipe.docker_image,
+                    &checksum[..12.min(checksum.len())],
+                )
+            {
+                println!(
+                    "[dry-run] --image-source registry: would pull {} → tag '{}' on '{}' (no build, no push)",
+                    version_ref, recipe.docker_image, build_node_name
+                );
+                return Ok(false);
+            }
+            println!(
+                "[dry-run] --image-source registry: would pull the prebuilt image of '{}' on '{}' (no build, no push)",
+                recipe.docker_image, build_node_name
+            );
+            return Ok(false);
+        }
         let dir = recipe.docker_image_dir.as_deref().unwrap_or(recipe_name);
         let review = resolve_build_dir(
             Path::new("."),
@@ -252,6 +278,39 @@ pub async fn handle_build(
         &recipe.build.versions,
     )?;
 
+    // Registry mode: pull the prebuilt tag (checksum-labeled) on the build
+    // node — no build, no push (the image already lives in [registry]).
+    if opts.image_source == super::ImageSource::Registry {
+        let reg = config
+            .registry
+            .as_ref()
+            .context("--image-source registry requires a [registry] section")?;
+        let (version_ref, _) = push::registry_refs(
+            &reg.base,
+            &recipe.docker_image,
+            &checksum[..12.min(checksum.len())],
+        )
+        .with_context(|| {
+            format!(
+                "cannot map image '{}' to a registry ref under base '{}'",
+                recipe.docker_image, reg.base
+            )
+        })?;
+        super::registry_pull::ensure_node(
+            config,
+            build_node,
+            &recipe.docker_image,
+            &checksum,
+            &version_ref,
+        )
+        .await?;
+        info!(
+            "registry mode: '{}' ensured on '{}' from [registry] — skipping build + push",
+            recipe.docker_image, build_node.name
+        );
+        return Ok(false);
+    }
+
     let should_build = if opts.force_build {
         true
     } else {
@@ -322,17 +381,19 @@ pub async fn handle_build(
     Ok(should_build)
 }
 
-pub async fn handle_build_cmd(
-    config: &Config,
-    recipe: &str,
-    build_node: &str,
-    cluster: &str,
-    force_build: bool,
-    no_push: bool,
-    dry_run: bool,
-) -> Result<()> {
-    let recipe_obj = crate::config::load_recipe(recipe, &config.recipe_sources).await?;
-    let cluster = crate::config::resolve_build_cluster(config, cluster, build_node)?;
+/// Args for `sm121 build` (bundled to keep the handler's arg count down).
+pub struct BuildCmdArgs {
+    pub recipe: String,
+    pub build_node: String,
+    pub cluster: String,
+    pub force_build: bool,
+    pub no_push: bool,
+    pub image_source: super::ImageSource,
+}
+
+pub async fn handle_build_cmd(config: &Config, args: &BuildCmdArgs, dry_run: bool) -> Result<()> {
+    let recipe_obj = crate::config::load_recipe(&args.recipe, &config.recipe_sources).await?;
+    let cluster = crate::config::resolve_build_cluster(config, &args.cluster, &args.build_node)?;
     let opts = super::ServeOpts {
         keep: false,
         skip_before_serve: true,
@@ -341,9 +402,10 @@ pub async fn handle_build_cmd(
         auto_power_on: false,
         sync_image: false,
         sync_image_source: None,
-        no_push,
-        build_node: Some(build_node.to_string()),
-        force_build,
+        no_push: args.no_push,
+        image_source: args.image_source,
+        build_node: Some(args.build_node.clone()),
+        force_build: args.force_build,
         force: false,
         solo: None,
         mirror: None,
@@ -352,7 +414,15 @@ pub async fn handle_build_cmd(
         fit: None,
         clear: false,
     };
-    handle_build(config, &cluster, recipe, &recipe_obj, &opts, build_node).await?;
+    handle_build(
+        config,
+        &cluster,
+        &args.recipe,
+        &recipe_obj,
+        &opts,
+        &args.build_node,
+    )
+    .await?;
     Ok(())
 }
 

+ 1 - 0
src/docker/command_tests.rs

@@ -51,6 +51,7 @@ fn minimal_config() -> Config {
         drop_caches: true,
         mount_aliases: HashMap::from([("models".to_string(), "/mnt/nas/models".to_string())]),
         recipe_sources: Vec::new(),
+        recipe_repos: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         registry: None,

+ 1 - 0
src/docker/mirror.rs

@@ -170,6 +170,7 @@ mod tests {
             drop_caches: true,
             mount_aliases: HashMap::new(),
             recipe_sources: Vec::new(),
+            recipe_repos: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
             registry: None,

+ 75 - 46
src/docker/mod.rs

@@ -11,6 +11,7 @@ pub(crate) mod path_ref;
 pub mod push;
 pub use logs::show_logs;
 pub mod build_dir;
+pub mod registry_pull;
 pub mod stale;
 mod start;
 pub mod stop;
@@ -24,6 +25,20 @@ use crate::{
 use anyhow::{Context, Result};
 
 use tracing::{info, warn};
+
+/// Where a recipe's Docker image comes from (`--image-source`):
+/// `repo` (default) = the current on-demand build from `docker-images/`
+/// (build context in the launcher workspace / recipe repos); `registry`
+/// = pull the prebuilt `{base}/{name}:{checksum}` from the `[registry]`
+/// base and skip the build (the image must already be pushed).
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, clap::ValueEnum)]
+pub enum ImageSource {
+    /// Build on demand from `docker-images/` (default, current behavior).
+    #[default]
+    Repo,
+    /// Pull the prebuilt image from the `[registry]` base (skip the build).
+    Registry,
+}
 /// Options for the serve operation.
 #[derive(Clone, Debug)]
 pub struct ServeOpts {
@@ -37,6 +52,9 @@ pub struct ServeOpts {
     pub sync_image_source: Option<String>,
     /// Skip the post-build registry push even when `[registry]` is set.
     pub no_push: bool,
+    /// Where the image comes from: build from `docker-images/` (default)
+    /// or pull the prebuilt tag from `[registry]`.
+    pub image_source: ImageSource,
     pub build_node: Option<String>,
     pub force_build: bool,
     /// Ignore occupied nodes during cluster selection (`--force`).
@@ -203,28 +221,10 @@ pub async fn serve(
 
     // `built_on` records the node that (re)built the image: the sync step
     // below must pull FROM it, never let a peer's older image overwrite the
-    // new build.
+    // new build. Registry mode has no build (and no node-to-node sync —
+    // every node pulls the identical tag from [registry]).
     let mut built_on: Option<String> = None;
-    if let Some(ref build_node_name) = opts.build_node {
-        if !opts.dry_run && !cluster.nodes.iter().any(|n| n.name == *build_node_name) {
-            anyhow::bail!("build-node '{}' not in cluster", build_node_name);
-        }
-        if !opts.dry_run {
-            let did_build = crate::docker::build::handle_build(
-                config,
-                &cluster,
-                recipe_name,
-                &recipe,
-                &opts,
-                build_node_name,
-            )
-            .await?;
-            if did_build {
-                built_on = Some(build_node_name.clone());
-            }
-        }
-    }
-
+    // Mods upload is orthogonal to the image source — both modes need it.
     let mods_remote_path = mods::upload_mods_to_cluster(
         config,
         &cluster,
@@ -234,31 +234,60 @@ pub async fn serve(
         opts.dry_run,
     )
     .await?;
-    let image = try_repaired_image(config, &cluster, &recipe).await?;
-    if image != recipe.docker_image {
-        recipe.docker_image = image.clone();
-    }
-    // Surface image staleness (bumped Dockerfile vs image checksum label)
-    // when not building. Non-blocking; runs on dry-run too (read-only inspect).
-    if opts.build_node.is_none() {
-        stale::warn_if_stale(config, &cluster, &recipe).await?;
-    }
-    // Sync Docker image across nodes if needed.
-    let should_sync =
-        opts.sync_image && recipe.sync_image && !opts.dry_run && cluster.nodes.len() > 1;
-    if should_sync {
-        // After a fresh build the build node holds the authoritative image:
-        // force it as the sync source so a peer's older image can't overwrite
-        // the new build. The "newest creation timestamp" heuristic only
-        // applies when nothing was built here and no explicit
-        // --sync-image-source was given.
-        let source = built_on.as_deref().or(opts.sync_image_source.as_deref());
-        self::sync::sync_image_to_nodes(config, &cluster, &recipe.docker_image, source).await?;
-    }
-    // Prebuilt external images (no `docker_image_dir`) are not built by the
-    // launcher — ensure they exist on the launch nodes BEFORE any `docker run`.
-    if !opts.dry_run && recipe.docker_image_dir.is_none() {
-        self::sync::ensure_prebuilt_image(config, &cluster, &recipe.docker_image).await?;
+    if opts.image_source == ImageSource::Registry {
+        if opts.build_node.is_some() {
+            anyhow::bail!(
+                "--image-source registry and --build-node are mutually exclusive \u{2014} registry mode never builds (the image is pulled from [registry])"
+            );
+        }
+        self::registry_pull::ensure_registry_image(config, &cluster, &recipe, opts.dry_run).await?;
+    } else {
+        if let Some(ref build_node_name) = opts.build_node {
+            if !opts.dry_run && !cluster.nodes.iter().any(|n| n.name == *build_node_name) {
+                anyhow::bail!("build-node '{}' not in cluster", build_node_name);
+            }
+            if !opts.dry_run {
+                let did_build = crate::docker::build::handle_build(
+                    config,
+                    &cluster,
+                    recipe_name,
+                    &recipe,
+                    &opts,
+                    build_node_name,
+                )
+                .await?;
+                if did_build {
+                    built_on = Some(build_node_name.clone());
+                }
+            }
+        }
+
+        let image = try_repaired_image(config, &cluster, &recipe).await?;
+        if image != recipe.docker_image {
+            recipe.docker_image = image.clone();
+        }
+        // Surface image staleness (bumped Dockerfile vs image checksum label)
+        // when not building. Non-blocking; runs on dry-run too (read-only inspect).
+        if opts.build_node.is_none() {
+            stale::warn_if_stale(config, &cluster, &recipe).await?;
+        }
+        // Sync Docker image across nodes if needed.
+        let should_sync =
+            opts.sync_image && recipe.sync_image && !opts.dry_run && cluster.nodes.len() > 1;
+        if should_sync {
+            // After a fresh build the build node holds the authoritative image:
+            // force it as the sync source so a peer's older image can't overwrite
+            // the new build. The "newest creation timestamp" heuristic only
+            // applies when nothing was built here and no explicit
+            // --sync-image-source was given.
+            let source = built_on.as_deref().or(opts.sync_image_source.as_deref());
+            self::sync::sync_image_to_nodes(config, &cluster, &recipe.docker_image, source).await?;
+        }
+        // Prebuilt external images (no `docker_image_dir`) are not built by the
+        // launcher — ensure they exist on the launch nodes BEFORE any `docker run`.
+        if !opts.dry_run && recipe.docker_image_dir.is_none() {
+            self::sync::ensure_prebuilt_image(config, &cluster, &recipe.docker_image).await?;
+        }
     }
 
     // Drop page cache on all nodes right before starting containers.

+ 226 - 0
src/docker/registry_pull.rs

@@ -0,0 +1,226 @@
+//! `--image-source registry`: pull the prebuilt image from the
+//! `[registry]` base instead of building it from `docker-images/`.
+//!
+//! The registry version tag IS the image's build checksum (the same value
+//! the build stamps as the `sm121.checksum` label and the push machinery
+//! uses as the version): `{base}/{name}:{checksum12}`. After the pull the
+//! image is tagged as the recipe's `docker_image` and the
+//! `sm121.checksum` label is verified — a missing tag fails with a clear
+//! error naming the tag + a hint to build/push first (or fall back to
+//! `--image-source repo`).
+
+use super::build_dir::{build_checksum, resolve_build_dir};
+use super::push::registry_refs;
+use super::shq;
+use crate::config::{Cluster, Config, Node, Recipe};
+use crate::ssh::run_ssh;
+use anyhow::{Context, Result};
+use std::path::Path;
+use tracing::info;
+
+/// The registry refs for a recipe's image: `(version_ref, latest_ref,
+/// full_checksum)` — the version is the build checksum's first 12 chars
+/// (identical to the push machinery).
+pub fn registry_pull_refs(config: &Config, recipe: &Recipe) -> Result<(String, String, String)> {
+    let reg = config
+        .registry
+        .as_ref()
+        .context("--image-source registry requires a [registry] section in the config")?;
+    let image_dir = recipe
+        .docker_image_dir
+        .clone()
+        .unwrap_or_else(|| recipe.name.clone());
+    let (local, _source) = resolve_build_dir(
+        Path::new("."),
+        &recipe.name,
+        &image_dir,
+        &config.docker_image_sources,
+    )?;
+    let (_merged, checksum) = build_checksum(
+        &local,
+        recipe.build_profile.as_deref(),
+        &recipe.build.versions,
+    )?;
+    let version = &checksum[..12.min(checksum.len())];
+    let (version_ref, latest_ref) = registry_refs(&reg.base, &recipe.docker_image, version)
+        .context(format!(
+            "cannot map image '{}' to a registry ref under base '{}' (the image name must be a bare <name>[:tag])",
+            recipe.docker_image, reg.base
+        ))?;
+    Ok((version_ref, latest_ref, checksum))
+}
+
+/// The outcome of one node's pull-and-verify.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum NodeOutcome {
+    /// The image was already present with a matching `sm121.checksum` label.
+    Present,
+    /// Pulled from the registry, tagged, and the label verified.
+    Pulled,
+    /// `docker pull` failed (most likely the tag was never pushed).
+    PullFailed,
+    /// The pull succeeded but `docker tag` failed.
+    TagFailed,
+    /// The pulled image's `sm121.checksum` label differs (a tag collision
+    /// on the registry side — the version tag is not this build).
+    LabelMismatch(String),
+    /// The node's output was unparseable.
+    Failed(String),
+}
+
+/// Parse a node's script output (the last `SM121-*` line wins; ssh output
+/// may carry log noise before it). No marker at all → `Failed` with the
+/// last non-empty line (the ssh error body).
+pub fn parse_node_output(out: &str) -> NodeOutcome {
+    let trimmed = out.trim();
+    let marker = trimmed
+        .lines()
+        .rev()
+        .find(|l| l.trim_start().starts_with("SM121-"))
+        .map(str::trim);
+    match marker {
+        Some("SM121-PRESENT") => NodeOutcome::Present,
+        Some("SM121-PULLED") => NodeOutcome::Pulled,
+        Some(line) if line.starts_with("SM121-PULL-FAILED") => NodeOutcome::PullFailed,
+        Some(line) if line.starts_with("SM121-TAG-FAILED") => NodeOutcome::TagFailed,
+        Some(line) if line.starts_with("SM121-LABEL-MISMATCH") => NodeOutcome::LabelMismatch(
+            line.strip_prefix("SM121-LABEL-MISMATCH")
+                .unwrap()
+                .trim()
+                .to_string(),
+        ),
+        _ => NodeOutcome::Failed(
+            trimmed
+                .lines()
+                .rev()
+                .find(|l| !l.trim().is_empty())
+                .unwrap_or("")
+                .trim()
+                .to_string(),
+        ),
+    }
+}
+
+/// The per-node script: label-check → pull → tag → re-verify, one SSH
+/// round-trip. Prints a machine-readable `SM121-*` marker last.
+fn node_script(image: &str, expected: &str, version_ref: &str) -> String {
+    let inspect = |img: &str| {
+        format!(
+            "docker image inspect --format '{{{{index .Config.Labels \"sm121.checksum\"}}}}' {} 2>/dev/null || true",
+            shq(img)
+        )
+    };
+    format!(
+        r#"LABEL=$( {inspect} )
+if [ "$LABEL" = "{expected}" ]; then echo SM121-PRESENT; exit 0; fi
+if ! docker pull {ref} ; then echo SM121-PULL-FAILED; exit 1; fi
+if ! docker tag {ref} {img} ; then echo SM121-TAG-FAILED; exit 1; fi
+NEW=$( {inspect2} )
+if [ "$NEW" = "{expected}" ]; then echo SM121-PULLED; else echo "SM121-LABEL-MISMATCH $NEW"; fi"#,
+        inspect = inspect(image),
+        inspect2 = inspect(image),
+        expected = expected,
+        ref = shq(version_ref),
+        img = shq(image),
+    )
+}
+
+/// Pull + tag + label-verify the registry image on ONE node.
+pub async fn ensure_node(
+    config: &Config,
+    node: &Node,
+    image: &str,
+    expected_checksum: &str,
+    version_ref: &str,
+) -> Result<NodeOutcome> {
+    let out = run_ssh(
+        config,
+        node,
+        node_script(image, expected_checksum, version_ref),
+        false,
+    )
+    .await
+    .with_context(|| format!("registry pull on '{}'", node.name))?;
+    let outcome = parse_node_output(&out);
+    match &outcome {
+        NodeOutcome::Present => {
+            info!(
+                "registry image '{}' already present on '{}' (checksum label matches)",
+                image, node.name
+            );
+        }
+        NodeOutcome::Pulled => {
+            info!(
+                "pulled '{}' → '{}' on '{}' (checksum label verified)",
+                version_ref, image, node.name
+            );
+        }
+        _ => {}
+    }
+    Ok(outcome)
+}
+
+/// Pull + verify the registry image on EVERY launch node (in parallel).
+/// A missing tag fails loudly: the error names the tag and points at the
+/// build/push path (or the `--image-source repo` fallback).
+pub async fn ensure_registry_image(
+    config: &Config,
+    cluster: &Cluster,
+    recipe: &Recipe,
+    dry_run: bool,
+) -> Result<()> {
+    let (version_ref, _latest_ref, checksum) = registry_pull_refs(config, recipe)?;
+    if dry_run {
+        println!(
+            "[dry-run] --image-source registry: pull {} → tag '{}' on {} node(s) (checksum {}…)",
+            version_ref,
+            recipe.docker_image,
+            cluster.nodes.len(),
+            &checksum[..8.min(checksum.len())]
+        );
+        return Ok(());
+    }
+    let mut handles = Vec::new();
+    for node in &cluster.nodes {
+        let (config, node, image, expected, ref_) = (
+            config.clone(),
+            node.clone(),
+            recipe.docker_image.clone(),
+            checksum.clone(),
+            version_ref.clone(),
+        );
+        handles.push(crate::locale::spawn_with_locale(async move {
+            let name = node.name.clone();
+            match ensure_node(&config, &node, &image, &expected, &ref_).await {
+                Ok(o) => (name, Ok(o)),
+                Err(e) => (name, Err(e)),
+            }
+        }));
+    }
+    let mut failed: Vec<String> = Vec::new();
+    for h in handles {
+        let (name, res) = h.await?;
+        match res {
+            Ok(NodeOutcome::Present) | Ok(NodeOutcome::Pulled) => {}
+            Ok(NodeOutcome::PullFailed) => failed.push(format!(
+                "{name}: tag '{version_ref}' not found in the registry — build + push it first (sm121 build --recipe {name_of} --build-node <node>), or fall back to --image-source repo",
+                name_of = recipe.name
+            )),
+            Ok(NodeOutcome::TagFailed) => failed.push(format!("{name}: docker tag failed after the pull")),
+            Ok(NodeOutcome::LabelMismatch(got)) => failed.push(format!(
+                "{name}: the pulled image's sm121.checksum label is '{got}', expected '{checksum}' — the registry tag does not carry this build"
+            )),
+            Ok(NodeOutcome::Failed(out)) => failed.push(format!("{name}: unparseable pull output: {out}")),
+            Err(e) => failed.push(format!("{name}: {e}")),
+        }
+    }
+    if failed.is_empty() {
+        Ok(())
+    } else {
+        anyhow::bail!("--image-source registry failed on: {}", failed.join("; "))
+    }
+}
+
+#[cfg(test)]
+#[path = "registry_pull_tests.rs"]
+mod tests;

+ 114 - 0
src/docker/registry_pull_tests.rs

@@ -0,0 +1,114 @@
+//! Tests for `registry_pull` — the pure parts (output parsing, ref
+//! mapping, the node script); the SSH pull itself is integration-only.
+
+use super::{NodeOutcome, node_script, parse_node_output, registry_pull_refs};
+use crate::config::{Config, Recipe};
+use crate::docker::build_dir::compute_dir_checksum;
+use std::path::PathBuf;
+
+#[test]
+fn parse_node_output_recognizes_all_markers() {
+    assert_eq!(parse_node_output("SM121-PRESENT"), NodeOutcome::Present);
+    assert_eq!(
+        parse_node_output("noise\nSM121-PULLED"),
+        NodeOutcome::Pulled
+    );
+    assert_eq!(
+        parse_node_output("docker: pulling\nSM121-PULL-FAILED"),
+        NodeOutcome::PullFailed
+    );
+    assert_eq!(
+        parse_node_output("SM121-TAG-FAILED"),
+        NodeOutcome::TagFailed
+    );
+    assert_eq!(
+        parse_node_output("SM121-LABEL-MISMATCH abc123"),
+        NodeOutcome::LabelMismatch("abc123".into())
+    );
+    // no marker at all (an ssh error body) → Failed with the last line
+    assert_eq!(
+        parse_node_output("ssh: connect failed"),
+        NodeOutcome::Failed("ssh: connect failed".into())
+    );
+}
+
+#[test]
+fn node_script_carries_the_marker_flow() {
+    let s = node_script(
+        "myimg:tag",
+        "deadbeefcafe",
+        "ghcr.io/kilork/myimg:deadbeefcafe",
+    );
+    assert!(s.contains("SM121-PRESENT"), "{s}");
+    assert!(s.contains("docker pull"), "{s}");
+    assert!(s.contains("ghcr.io/kilork/myimg:deadbeefcafe"), "{s}");
+    assert!(s.contains("docker tag"), "{s}");
+    assert!(s.contains("SM121-LABEL-MISMATCH"), "{s}");
+    // the expected checksum is embedded exactly once per comparison
+    assert!(s.contains(r#""deadbeefcafe""#), "{s}");
+}
+
+#[test]
+fn registry_pull_refs_maps_image_to_checksum_tag() {
+    let root: PathBuf = std::env::temp_dir().join(format!("sm121-regpull-{}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&root);
+    let img_dir = root.join("docker-images/myimg");
+    std::fs::create_dir_all(&img_dir).unwrap();
+    std::fs::write(img_dir.join("build.sh"), "#!/bin/sh\necho hi\n").unwrap();
+
+    let cfg: Config = toml::from_str(&format!(
+        r#"
+docker_image_sources = ["{}"]
+[registry]
+base = "ghcr.io/kilork"
+"#,
+        root.display()
+    ))
+    .unwrap();
+    // the ref uses the image's bare name; the recipe's local tag is dropped
+    let recipe: Recipe = toml::from_str(
+        r#"
+name = "r1"
+engine = "vllm"
+docker_image = "myimg:tag"
+docker_image_dir = "myimg"
+"#,
+    )
+    .unwrap();
+    let (version_ref, latest_ref, checksum) = registry_pull_refs(&cfg, &recipe).unwrap();
+    let expected = compute_dir_checksum(&img_dir).unwrap();
+    assert_eq!(checksum, expected, "checksum = recursive dir checksum");
+    assert_eq!(
+        version_ref,
+        format!("ghcr.io/kilork/myimg:{}", &expected[..12])
+    );
+    assert_eq!(latest_ref, "ghcr.io/kilork/myimg:latest");
+    let _ = std::fs::remove_dir_all(&root);
+}
+
+#[test]
+fn registry_pull_refs_requires_a_registry_section() {
+    let root: PathBuf =
+        std::env::temp_dir().join(format!("sm121-regpull-noreg-{}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&root);
+    let img_dir = root.join("docker-images/myimg");
+    std::fs::create_dir_all(&img_dir).unwrap();
+    std::fs::write(img_dir.join("build.sh"), "#!/bin/sh\n").unwrap();
+    let cfg: Config = toml::from_str(&format!(
+        "docker_image_sources = [\"{}\"]\n",
+        root.display()
+    ))
+    .unwrap();
+    let recipe: Recipe = toml::from_str(
+        r#"
+name = "r1"
+engine = "vllm"
+docker_image = "myimg:tag"
+docker_image_dir = "myimg"
+"#,
+    )
+    .unwrap();
+    let err = registry_pull_refs(&cfg, &recipe).expect_err("no [registry] → error");
+    assert!(err.to_string().contains("[registry]"), "{}", err);
+    let _ = std::fs::remove_dir_all(&root);
+}

+ 1 - 0
src/exec.rs

@@ -172,6 +172,7 @@ mod tests {
             drop_caches: true,
             mount_aliases: HashMap::new(),
             recipe_sources: Vec::new(),
+            recipe_repos: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
             registry: None,

+ 41 - 5
src/main.rs

@@ -34,9 +34,11 @@ mod mikrotik;
 mod orchestrate;
 mod power;
 mod pre_serve;
+mod promote;
 mod publish;
 mod publish_render;
 mod readiness;
+mod recipe_repos;
 mod registry;
 mod report;
 mod runlock;
@@ -71,6 +73,22 @@ async fn main() -> Result<()> {
     let mut config: Config = toml::from_str(&raw_config)?;
     crate::config::merge_cluster_sources(&mut config).await?;
     let dry_run = cli.dry_run;
+    // Recipe repos: clone/pull the configured git repos into the local
+    // cache and append their roots to the recipe + image-build search
+    // paths (first repo wins on a recipe-name collision). Best-effort —
+    // an unreachable repo degrades to a warning and never blocks a serve
+    // of a recipe from a reachable repo. Skipped in dry-run (no git I/O).
+    if !config.recipe_repos.is_empty() && !dry_run {
+        let roots = crate::recipe_repos::sync_recipe_repos(&config).await;
+        for r in &roots {
+            // recipe search: the repo's `recipes/` dir (each `<name>/recipe.toml`);
+            // image builds: the repo ROOT (its `docker-images/<dir>` build contexts)
+            config
+                .recipe_sources
+                .push(r.join("recipes").display().to_string());
+            config.docker_image_sources.push(r.display().to_string());
+        }
+    }
     // `config` subcommands are pure path/file utilities: dispatch them
     // before cluster_sources merging so they work even with broken sources.
     if let Commands::Config(cmd) = &cli.command {
@@ -99,6 +117,7 @@ async fn main() -> Result<()> {
             fit,
             clear,
             no_push,
+            image_source,
         } => {
             handle_serve(
                 &config,
@@ -114,6 +133,7 @@ async fn main() -> Result<()> {
                     sync_image,
                     sync_image_source,
                     no_push,
+                    image_source,
                     build_node,
                     force_build,
                     force,
@@ -139,14 +159,18 @@ async fn main() -> Result<()> {
             cluster,
             force_build,
             no_push,
+            image_source,
         } => {
             docker::build::handle_build_cmd(
                 &config,
-                &recipe,
-                &build_node,
-                &cluster,
-                force_build,
-                no_push,
+                &docker::build::BuildCmdArgs {
+                    recipe,
+                    build_node,
+                    cluster,
+                    force_build,
+                    no_push,
+                    image_source,
+                },
                 dry_run,
             )
             .await?;
@@ -287,6 +311,18 @@ async fn main() -> Result<()> {
             .await?;
         }
         Commands::Runlock(cmd) => crate::runlock::run(&config, &cmd)?,
+        Commands::Repos(a) => {
+            let cmd = a.command.clone().unwrap_or(crate::cli::ReposCommands::List);
+            crate::recipe_repos::run(&config, &cmd, dry_run).await?
+        }
+        Commands::Archive { url } => crate::recipe_repos::archive(&url, false, dry_run)?,
+        Commands::Unarchive { url } => crate::recipe_repos::archive(&url, true, dry_run)?,
+        Commands::Promote {
+            recipe,
+            from,
+            to,
+            apply,
+        } => crate::promote::run(&config, &recipe, &from, &to, apply, dry_run)?,
         Commands::Validate => {
             let v = crate::validate::validate_all(&config);
             let errs = v

+ 1 - 0
src/orchestrate/tests.rs

@@ -93,6 +93,7 @@ fn test_config(clusters: Vec<Cluster>) -> Config {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        recipe_repos: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         registry: None,

+ 313 - 0
src/promote.rs

@@ -0,0 +1,313 @@
+//! `sm121 promote` — move a recipe across recipe repos (git mv, decomposed
+//! into per-repo commit + push).
+//!
+//! The target is updated FIRST (copy in + commit + push); only then is the
+//! recipe removed from the source. A failure in between therefore leaves
+//! the recipe duplicated (safe under the priority semantics — the source
+//! repo shadows the target) and never deleted. `--dry-run` (the global
+//! `-d`) prints the exact commands; `--apply` runs them.
+
+use crate::config::{Config, RecipeRepo};
+use crate::recipe_repos::{cache_base, git, repo_cache_dir, sync_repo};
+use anyhow::{Context, Result};
+use std::path::{Path, PathBuf};
+
+/// One executable step of a promotion plan.
+#[derive(Debug, Clone)]
+pub struct Step {
+    /// `"target"` (the repo the recipe moves INTO) or `"source"`.
+    pub side: &'static str,
+    /// Human-readable form (what `--dry-run` prints).
+    pub description: String,
+    /// What `execute` runs.
+    pub run: Run,
+}
+
+#[derive(Debug, Clone)]
+pub enum Run {
+    /// `cp -a` the recipe dir between the two checkouts (a git mv across
+    /// repos has no single git command).
+    Copy { from: PathBuf, to: PathBuf },
+    /// `git -C <cwd> <args…>`.
+    Git { cwd: PathBuf, args: Vec<String> },
+    /// `git -C <cwd> push <url> <branch>` (the URL may carry the token).
+    Push {
+        cwd: PathBuf,
+        url: String,
+        branch: String,
+    },
+}
+
+/// The full promotion plan (target steps first, then source steps).
+#[derive(Debug, Clone)]
+pub struct PromotePlan {
+    pub recipe: String,
+    pub from_url: String,
+    pub to_url: String,
+    pub from_checkout: PathBuf,
+    pub to_checkout: PathBuf,
+    pub steps: Vec<Step>,
+}
+
+/// Find a configured `[[recipe_repos]]` entry by exact URL.
+fn find_repo<'a>(config: &'a Config, url: &str) -> Result<&'a RecipeRepo> {
+    config
+        .recipe_repos
+        .iter()
+        .find(|r| r.url == url)
+        .with_context(|| {
+            format!(
+                "repo '{url}' is not a configured [[recipe_repos]] entry — add it to the config (the promote needs its token for the push)"
+            )
+        })
+}
+
+/// The checkout dir of a repo, synced on demand (a promote must not plan
+/// against a missing checkout). With `sync_missing = false` (dry-run) a
+/// missing checkout is an error instead of a side-effecting clone.
+fn ensure_checkout(repo: &RecipeRepo, sync_missing: bool) -> Result<PathBuf> {
+    let dir = repo_cache_dir(&cache_base(), &repo.url);
+    if !dir.join(".git").is_dir() {
+        if !sync_missing {
+            anyhow::bail!(
+                "repo '{}' is not synced — run `sm121 repos sync` (dry-run: no git I/O)",
+                repo.url
+            );
+        }
+        sync_repo(repo).with_context(|| {
+            format!(
+                "repo '{}' is not synced — sync it first (sm121 repos sync)",
+                repo.url
+            )
+        })?;
+    }
+    Ok(dir)
+}
+
+fn branch_of(checkout: &Path) -> Result<String> {
+    let b = git(&[
+        "-C",
+        &checkout.display().to_string(),
+        "rev-parse",
+        "--abbrev-ref",
+        "HEAD",
+    ])?;
+    if b.is_empty() || b == "HEAD" {
+        anyhow::bail!(
+            "checkout {} is in detached HEAD — check out a branch first",
+            checkout.display()
+        );
+    }
+    Ok(b)
+}
+
+fn shq(s: &str) -> String {
+    format!("'{}'", s.replace('\'', "'\\''"))
+}
+
+/// Build the promotion plan: validate the move (the recipe must exist in
+/// the source and NOT in the target — no silent overwrite), then the
+/// target-side copy + commit + push, then the source-side remove + commit
+/// + push.
+pub fn plan(
+    config: &Config,
+    recipe: &str,
+    from_url: &str,
+    to_url: &str,
+    sync_missing: bool,
+) -> Result<PromotePlan> {
+    if from_url == to_url {
+        anyhow::bail!("--from and --to must be different repos");
+    }
+    let from = find_repo(config, from_url)?;
+    let to = find_repo(config, to_url)?;
+    let from_dir = ensure_checkout(from, sync_missing)?;
+    let to_dir = ensure_checkout(to, sync_missing)?;
+
+    let from_recipe = from_dir.join("recipes").join(recipe);
+    let to_recipe = to_dir.join("recipes").join(recipe);
+    if !from_recipe.is_dir() {
+        anyhow::bail!(
+            "recipe '{}' not found in the source repo's checkout ({}) — is the checkout current? (sm121 repos sync)",
+            recipe,
+            from_dir.display()
+        );
+    }
+    if to_recipe.exists() {
+        anyhow::bail!(
+            "recipe '{}' already exists in the target repo — refusing to overwrite (remove it there first)",
+            recipe
+        );
+    }
+
+    let from_branch = branch_of(&from_dir)?;
+    let to_branch = branch_of(&to_dir)?;
+
+    let mut steps = Vec::new();
+    steps.push(Step {
+        side: "target",
+        description: format!(
+            "cp -a {} {}",
+            shq(&from_recipe.display().to_string()),
+            shq(&to_recipe.display().to_string())
+        ),
+        run: Run::Copy {
+            from: from_recipe.clone(),
+            to: to_recipe.clone(),
+        },
+    });
+    steps.push(Step {
+        side: "target",
+        description: format!("git -C {} add recipes/{}", to_dir.display(), recipe),
+        run: Run::Git {
+            cwd: to_dir.clone(),
+            args: vec!["add".into(), format!("recipes/{recipe}")],
+        },
+    });
+    let to_commit_msg = format!("promote {recipe}: add from {from_url}");
+    steps.push(Step {
+        side: "target",
+        description: format!(
+            "git -C {} commit -m {}",
+            to_dir.display(),
+            shq(&to_commit_msg)
+        ),
+        run: Run::Git {
+            cwd: to_dir.clone(),
+            args: vec!["commit".into(), "-m".into(), to_commit_msg],
+        },
+    });
+    steps.push(Step {
+        side: "target",
+        description: format!("git -C {} push {} {}", to_dir.display(), to.url, to_branch),
+        run: Run::Push {
+            cwd: to_dir.clone(),
+            url: crate::recipe_repos::push_url(to),
+            branch: to_branch.clone(),
+        },
+    });
+    steps.push(Step {
+        side: "source",
+        description: format!("git -C {} rm -r recipes/{}", from_dir.display(), recipe),
+        run: Run::Git {
+            cwd: from_dir.clone(),
+            args: vec!["rm".into(), "-r".into(), format!("recipes/{recipe}")],
+        },
+    });
+    let from_commit_msg = format!("promote {recipe}: remove (promoted to {to_url})");
+    steps.push(Step {
+        side: "source",
+        description: format!(
+            "git -C {} commit -m {}",
+            from_dir.display(),
+            shq(&from_commit_msg)
+        ),
+        run: Run::Git {
+            cwd: from_dir.clone(),
+            args: vec!["commit".into(), "-m".into(), from_commit_msg],
+        },
+    });
+    steps.push(Step {
+        side: "source",
+        description: format!(
+            "git -C {} push {} {}",
+            from_dir.display(),
+            from.url,
+            from_branch
+        ),
+        run: Run::Push {
+            cwd: from_dir.clone(),
+            url: crate::recipe_repos::push_url(from),
+            branch: from_branch,
+        },
+    });
+
+    Ok(PromotePlan {
+        recipe: recipe.to_string(),
+        from_url: from_url.to_string(),
+        to_url: to_url.to_string(),
+        from_checkout: from_dir,
+        to_checkout: to_dir,
+        steps,
+    })
+}
+
+/// Run every step of the plan (in order; a failure stops the promote,
+/// leaving the recipe at least duplicated, never deleted).
+pub fn execute(plan: &PromotePlan) -> Result<()> {
+    for step in &plan.steps {
+        match &step.run {
+            Run::Copy { from, to } => {
+                std::fs::create_dir_all(to.parent().context("target recipes/ dir")?)?;
+                let out = std::process::Command::new("cp")
+                    .arg("-a")
+                    .arg(from)
+                    .arg(to)
+                    .output()
+                    .context("running cp")?;
+                if !out.status.success() {
+                    anyhow::bail!("cp failed: {}", String::from_utf8_lossy(&out.stderr).trim());
+                }
+            }
+            Run::Git { cwd, args } => {
+                let cwd_s = cwd.display().to_string();
+                let mut a: Vec<&str> = vec!["-C", &cwd_s];
+                a.extend(args.iter().map(String::as_str));
+                git(&a)?;
+            }
+            Run::Push { cwd, url, branch } => {
+                git(&["-C", &cwd.display().to_string(), "push", url, branch])?;
+            }
+        }
+        println!("  [{}] {}", step.side, step.description);
+    }
+    Ok(())
+}
+
+/// `sm121 promote <recipe> --from <url> --to <url> [--apply]` dispatch:
+/// plan, print (default / `-d`), execute (`--apply`).
+pub fn run(
+    config: &Config,
+    recipe: &str,
+    from_url: &str,
+    to_url: &str,
+    apply: bool,
+    dry_run: bool,
+) -> Result<()> {
+    let plan = plan(config, recipe, from_url, to_url, !dry_run)?;
+    if apply && !dry_run {
+        println!(
+            "promoting '{}' from {} to {} ({} step(s))",
+            plan.recipe,
+            plan.from_url,
+            plan.to_url,
+            plan.steps.len()
+        );
+        println!("  source checkout: {}", plan.from_checkout.display());
+        println!("  target checkout: {}", plan.to_checkout.display());
+        execute(&plan)?;
+        println!(
+            "promoted '{}' — the target now has it; the next sync of both repos keeps the move",
+            plan.recipe
+        );
+        return Ok(());
+    }
+    println!(
+        "[dry-run] promote '{}' from {} to {}:",
+        plan.recipe, plan.from_url, plan.to_url
+    );
+    println!("  source checkout: {}", plan.from_checkout.display());
+    println!("  target checkout: {}", plan.to_checkout.display());
+    for step in &plan.steps {
+        println!("  [{}] {}", step.side, step.description);
+    }
+    if apply {
+        println!("(suppressed: -d dry-run wins over --apply)");
+    }
+    println!("re-run with --apply to execute");
+    Ok(())
+}
+
+#[cfg(test)]
+#[path = "promote_tests.rs"]
+mod tests;

+ 226 - 0
src/promote_tests.rs

@@ -0,0 +1,226 @@
+//! Tests for `promote` — hermetic `file://` git repos (no network).
+
+use super::{PromotePlan, execute, plan};
+use crate::config::{Config, RecipeRepo};
+use std::path::Path;
+
+fn git_ok(dir: &Path, args: &[&str]) {
+    let out = std::process::Command::new("git")
+        .arg("-C")
+        .arg(dir)
+        .args(args)
+        .output()
+        .expect("run git");
+    assert!(
+        out.status.success(),
+        "git {args:?} in {dir:?} failed: {}",
+        String::from_utf8_lossy(&out.stderr)
+    );
+}
+
+/// A bare git repo (a `file://` "remote" — pushes land on it; a non-bare
+/// repo would reject pushes to its checked-out branch) with
+/// `recipes/demo/recipe.toml` committed.
+fn make_recipe_repo(tag: &str) -> (std::path::PathBuf, String) {
+    let work =
+        std::env::temp_dir().join(format!("sm121-promote-{tag}-work-{}", std::process::id()));
+    let bare =
+        std::env::temp_dir().join(format!("sm121-promote-{tag}-bare-{}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&work);
+    let _ = std::fs::remove_dir_all(&bare);
+    let recipe = work.join("recipes/demo");
+    std::fs::create_dir_all(&recipe).unwrap();
+    std::fs::write(
+        recipe.join("recipe.toml"),
+        "engine = \"vllm\"\nnote = \"demo\"\n",
+    )
+    .unwrap();
+    let w = work.display().to_string();
+    git_ok(&work, &["init", "-q"]);
+    git_ok(&work, &["config", "user.email", "t@sm121.test"]);
+    git_ok(&work, &["config", "user.name", "sm121-test"]);
+    git_ok(&work, &["add", "-A"]);
+    git_ok(&work, &["commit", "-qm", "init"]);
+    git_ok(
+        &work,
+        &["clone", "--bare", &w, bare.display().to_string().as_str()],
+    );
+    (bare.clone(), format!("file://{}", bare.display()))
+}
+
+/// An empty (one README commit) bare git repo — a promotion target.
+fn make_empty_repo(tag: &str) -> (std::path::PathBuf, String) {
+    let work = std::env::temp_dir().join(format!(
+        "sm121-promote-empty-{tag}-work-{}",
+        std::process::id()
+    ));
+    let bare = std::env::temp_dir().join(format!(
+        "sm121-promote-empty-{tag}-bare-{}",
+        std::process::id()
+    ));
+    let _ = std::fs::remove_dir_all(&work);
+    let _ = std::fs::remove_dir_all(&bare);
+    std::fs::create_dir_all(&work).unwrap();
+    std::fs::write(work.join("README.md"), "target\n").unwrap();
+    let w = work.display().to_string();
+    git_ok(&work, &["init", "-q"]);
+    git_ok(&work, &["config", "user.email", "t@sm121.test"]);
+    git_ok(&work, &["config", "user.name", "sm121-test"]);
+    git_ok(&work, &["add", "-A"]);
+    git_ok(&work, &["commit", "-qm", "init"]);
+    git_ok(
+        &work,
+        &["clone", "--bare", &w, bare.display().to_string().as_str()],
+    );
+    (bare.clone(), format!("file://{}", bare.display()))
+}
+
+fn config_with_repos(urls: &[String]) -> Config {
+    let repos: Vec<RecipeRepo> = urls
+        .iter()
+        .map(|u| RecipeRepo {
+            url: u.clone(),
+            token: None,
+        })
+        .collect();
+    Config {
+        ssh_user: "u".to_string(),
+        ssh_key_path: "/k".into(),
+        clusters: vec![],
+        nccl: std::collections::HashMap::new(),
+        hf_cache_dir: None,
+        init_port: 25000,
+        before_serve: std::collections::HashMap::new(),
+        exec_aliases: std::collections::HashMap::new(),
+        home_assistant: None,
+        mikrotik: None,
+        global_mods_dir: None,
+        drop_caches: true,
+        mount_aliases: std::collections::HashMap::new(),
+        recipe_sources: Vec::new(),
+        recipe_repos: repos,
+        docker_image_sources: Vec::new(),
+        cluster_sources: Vec::new(),
+        registry: None,
+    }
+}
+
+/// The checkouts `plan` syncs into (default cache base, keyed by URL) —
+/// cleaned up after the test.
+fn checkouts(urls: &[String]) -> Vec<std::path::PathBuf> {
+    urls.iter()
+        .map(|u| crate::recipe_repos::repo_cache_dir(&crate::recipe_repos::cache_base(), u))
+        .collect()
+}
+
+fn setup_identities(checkouts: &[std::path::PathBuf]) {
+    for c in checkouts {
+        if c.join(".git").is_dir() {
+            git_ok(c, &["config", "user.email", "t@sm121.test"]);
+            git_ok(c, &["config", "user.name", "sm121-test"]);
+        }
+    }
+}
+
+fn clean(c: &std::path::Path) {
+    let _ = std::fs::remove_dir_all(c);
+}
+
+#[test]
+fn plan_prints_steps_and_apply_moves_the_recipe() {
+    let (_src, a_url) = make_recipe_repo("src");
+    let (_dst, b_url) = make_empty_repo("dst");
+    let urls = vec![a_url.clone(), b_url.clone()];
+    let cfg = config_with_repos(&urls);
+
+    let p = plan(&cfg, "demo", &a_url, &b_url, true).expect("plan");
+    assert_eq!(
+        p.steps.len(),
+        7,
+        "copy + (add, commit, push) + (rm, commit, push)"
+    );
+    assert_eq!(p.steps[0].side, "target", "target side runs first");
+    let co = checkouts(&urls);
+    setup_identities(&co);
+
+    execute(&p).expect("execute");
+    // target: the recipe landed + a promote commit exists
+    assert!(co[1].join("recipes/demo/recipe.toml").is_file());
+    let log = git_out(&co[1], &["log", "--format=%s"]);
+    assert!(log.contains("promote demo"), "target log: {log}");
+    // source: the recipe is gone + a remove commit exists
+    assert!(!co[0].join("recipes/demo").exists());
+    let log = git_out(&co[0], &["log", "--format=%s"]);
+    assert!(log.contains("promote demo"), "source log: {log}");
+    for c in &co {
+        clean(c);
+    }
+}
+
+fn git_out(dir: &Path, args: &[&str]) -> String {
+    let out = std::process::Command::new("git")
+        .arg("-C")
+        .arg(dir)
+        .args(args)
+        .output()
+        .expect("run git");
+    String::from_utf8_lossy(&out.stdout).to_string()
+}
+
+#[test]
+fn dry_run_plan_has_no_side_effects() {
+    let (_src, a_url) = make_recipe_repo("dry-src");
+    let (_dst, b_url) = make_empty_repo("dry-dst");
+    let urls = vec![a_url.clone(), b_url.clone()];
+    let cfg = config_with_repos(&urls);
+    // pre-sync both checkouts (a dry-run plan must NOT clone on demand)
+    for u in &urls {
+        let repo = &cfg.recipe_repos[urls.iter().position(|x| x == u).unwrap()];
+        crate::recipe_repos::sync_repo(repo).expect("pre-sync");
+    }
+    let p: PromotePlan = plan(&cfg, "demo", &a_url, &b_url, false).expect("plan");
+    assert!(
+        !p.to_checkout.join("recipes/demo").exists(),
+        "planning never copies"
+    );
+    assert!(p.from_checkout.join("recipes/demo/recipe.toml").is_file());
+    let co = checkouts(&urls);
+    for c in &co {
+        clean(c);
+    }
+}
+
+#[test]
+fn collision_in_target_is_refused() {
+    let (_src, a_url) = make_recipe_repo("col-src");
+    let (_dst, b_url) = make_recipe_repo("col-dst"); // the recipe exists in BOTH
+    let urls = vec![a_url.clone(), b_url.clone()];
+    let cfg = config_with_repos(&urls);
+    let err = plan(&cfg, "demo", &a_url, &b_url, true).expect_err("collision must be refused");
+    let msg = err.to_string();
+    assert!(msg.contains("already exists"), "{msg}");
+    let co = checkouts(&urls);
+    for c in &co {
+        clean(c);
+    }
+}
+
+#[test]
+fn unknown_repo_url_is_refused() {
+    let (_src, a_url) = make_recipe_repo("unk-src");
+    let cfg = config_with_repos(std::slice::from_ref(&a_url));
+    let err = plan(&cfg, "demo", &a_url, "file:///not-configured", true)
+        .expect_err("unconfigured url must be refused");
+    assert!(err.to_string().contains("not a configured"), "{}", err);
+    clean(&checkouts(std::slice::from_ref(&a_url))[0]);
+}
+
+#[test]
+fn same_repo_for_from_and_to_is_refused() {
+    let (_src, a_url) = make_recipe_repo("same");
+    let cfg = config_with_repos(std::slice::from_ref(&a_url));
+    let err =
+        plan(&cfg, "demo", &a_url, &a_url, true).expect_err("same-repo promote must be refused");
+    assert!(err.to_string().contains("different repos"), "{}", err);
+    clean(&checkouts(&[a_url])[0]);
+}

+ 359 - 0
src/recipe_repos.rs

@@ -0,0 +1,359 @@
+//! Recipe repos: git repos holding `recipes/` (+ `docker-images/` build
+//! contexts), cloned/pulled by the launcher into a local cache and merged
+//! into the recipe search path (first repo wins on a name collision — see
+//! `Config.recipe_repos`).
+//!
+//! Archive state lives in a separate JSON registry (`repos.json` next to
+//! the global config): a `[[recipe_repos]]` entry can be archived without
+//! editing the config (`sm121 archive <url>` / `unarchive`), and the next
+//! sync skips archived repos. A repo that is unreachable at sync time
+//! degrades to a warning — a dead playground repo must not take down
+//! production serving.
+
+use crate::cli::ReposCommands;
+use crate::config::{RecipeRepo, default_config_dir};
+use anyhow::{Context, Result};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use std::path::{Path, PathBuf};
+use tracing::{info, warn};
+
+/// Stamp file written into a synced checkout after each successful
+/// clone/pull (unix-epoch seconds; shown by `sm121 repos`).
+pub const LAST_SYNC_FILE: &str = ".sm121-last-sync";
+
+/// Per-repo cache dir root: `~/.cache/sm121/recipe-repos`.
+pub fn cache_base() -> PathBuf {
+    dirs::home_dir()
+        .unwrap_or_else(|| PathBuf::from("."))
+        .join(".cache/sm121/recipe-repos")
+}
+
+/// `sha256(url)` hex (full; callers truncate for dir names). Stable per
+/// URL — the cache dir is keyed on the repo URL, not the hostname.
+pub(crate) fn url_hash(url: &str) -> String {
+    let digest = Sha256::digest(url.as_bytes());
+    digest.iter().map(|b| format!("{:02x}", b)).collect()
+}
+
+/// Cache checkout dir for one repo: `<base>/<sha256(url)16>`.
+pub fn repo_cache_dir(base: &Path, url: &str) -> PathBuf {
+    let h = url_hash(url);
+    base.join(&h[..16])
+}
+
+/// Run `git <args>` locally, capturing output. Stdout on success; an
+/// anyhow error carrying the stderr tail on failure.
+pub(crate) fn git(args: &[&str]) -> Result<String> {
+    let out = std::process::Command::new("git")
+        .args(args)
+        .output()
+        .with_context(|| {
+            format!(
+                "failed to run git (first arg: '{}')",
+                args.first().unwrap_or(&"")
+            )
+        })?;
+    if !out.status.success() {
+        let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
+        anyhow::bail!(
+            "git {} failed: {}",
+            args.first().copied().unwrap_or(""),
+            err
+        );
+    }
+    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
+}
+
+/// Clone URL with the optional token embedded (HTTPS only — for ssh URLs
+/// the token is a no-op and the URL is used as-is). The token lives only
+/// in the cached checkout's `.git/config`, never in a recipe repo.
+fn auth_url(repo: &RecipeRepo) -> String {
+    if let Some(t) = &repo.token
+        && (repo.url.starts_with("https://") || repo.url.starts_with("http://"))
+    {
+        return repo.url.replace("//", &format!("//{t}@"));
+    }
+    repo.url.clone()
+}
+
+/// The push URL for a repo (the token embedded for HTTPS pushes) — used
+/// by `promote` so the push authenticates without a credential store.
+pub fn push_url(repo: &RecipeRepo) -> String {
+    auth_url(repo)
+}
+
+/// Clone (first time) or `pull --ff-only` (subsequent) one repo into its
+/// cache dir; stamp `LAST_SYNC_FILE` on success. `force` re-clones from
+/// scratch (drops the cached checkout first).
+pub fn sync_one(url: &str, dir: &Path, force: bool) -> Result<()> {
+    if force && dir.exists() {
+        std::fs::remove_dir_all(dir)
+            .with_context(|| format!("removing cached checkout {}", dir.display()))?;
+    }
+    if dir.join(".git").is_dir() {
+        git(&["-C", &dir.display().to_string(), "pull", "--ff-only"])?;
+    } else {
+        if dir.exists() {
+            // partial clone from an interrupted run — start clean
+            std::fs::remove_dir_all(dir)?;
+        }
+        if let Some(parent) = dir.parent() {
+            std::fs::create_dir_all(parent)?;
+        }
+        git(&["clone", "--", url, &dir.display().to_string()])?;
+    }
+    let ts = crate::stamp::now_secs().to_string();
+    let stamp_path = dir.join(LAST_SYNC_FILE);
+    std::fs::write(&stamp_path, ts)
+        .with_context(|| format!("stamping {}", stamp_path.display()))?;
+    Ok(())
+}
+
+/// The archive registry: which `[[recipe_repos]]` URLs are currently
+/// archived (skipped by sync). Missing file → a fresh empty registry; a
+/// corrupt file is moved to `repos.json.bak` and replaced (the launcher
+/// never crashes on the registry).
+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
+pub struct ReposRegistry {
+    #[serde(default)]
+    pub archived: Vec<ArchiveEntry>,
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct ArchiveEntry {
+    pub url: String,
+    /// Unix-epoch seconds the entry was archived.
+    #[serde(default)]
+    pub at: i64,
+}
+
+impl ReposRegistry {
+    pub fn is_archived(&self, url: &str) -> bool {
+        self.archived.iter().any(|e| e.url == url)
+    }
+}
+
+/// `repos.json` next to the global config (`~/.config/sm121/` on Linux,
+/// `~/Library/Application Support/sm121/` on macOS).
+pub fn registry_path() -> Option<PathBuf> {
+    default_config_dir().map(|d| d.join("repos.json"))
+}
+
+/// Load the registry at `path` (missing → fresh; corrupt → `.bak` + fresh
+/// + a warning).
+pub fn load_registry(path: &Path) -> ReposRegistry {
+    match std::fs::read_to_string(path) {
+        Ok(s) => match serde_json::from_str(&s) {
+            Ok(r) => r,
+            Err(e) => {
+                warn!(
+                    "repos registry '{}' is corrupt ({e}) — moving it to '{}' and starting fresh",
+                    path.display(),
+                    path.with_extension("json.bak").display()
+                );
+                let bak = path.with_extension("json.bak");
+                if std::fs::copy(path, &bak).is_err() {
+                    warn!("could not back up corrupt registry {}", path.display());
+                }
+                ReposRegistry::default()
+            }
+        },
+        Err(_) => ReposRegistry::default(),
+    }
+}
+
+pub fn save_registry(path: &Path, reg: &ReposRegistry) -> Result<()> {
+    if let Some(parent) = path.parent() {
+        std::fs::create_dir_all(parent)?;
+    }
+    std::fs::write(path, serde_json::to_string_pretty(reg)?)?;
+    Ok(())
+}
+
+/// `sm121 archive <url>`: flip the entry in `repos.json` (idempotent).
+pub fn archive_at(path: &Path, url: &str) -> Result<()> {
+    let mut reg = load_registry(path);
+    if !reg.is_archived(url) {
+        reg.archived.push(ArchiveEntry {
+            url: url.to_string(),
+            at: crate::stamp::now_secs(),
+        });
+        save_registry(path, &reg)?;
+    }
+    Ok(())
+}
+
+/// `sm121 unarchive <url>`: drop the entry (a no-op when absent).
+pub fn unarchive_at(path: &Path, url: &str) -> Result<()> {
+    let mut reg = load_registry(path);
+    let before = reg.archived.len();
+    reg.archived.retain(|e| e.url != url);
+    if reg.archived.len() != before {
+        save_registry(path, &reg)?;
+    }
+    Ok(())
+}
+
+/// Clone/pull every non-archived `repos` entry (list order) into `base`,
+/// best-effort: an unreachable repo warns and is skipped, the reachable
+/// ones still land. Returns the checkout roots in list order.
+pub fn sync_repos(
+    repos: &[RecipeRepo],
+    base: &Path,
+    registry: &ReposRegistry,
+    force: bool,
+) -> Vec<PathBuf> {
+    let mut roots = Vec::new();
+    for repo in repos {
+        if registry.is_archived(&repo.url) {
+            info!("recipe repo archived — skipping: {}", repo.url);
+            continue;
+        }
+        let dir = repo_cache_dir(base, &repo.url);
+        match sync_one(&auth_url(repo), &dir, force) {
+            Ok(()) => {
+                info!("recipe repo synced: {} → {}", repo.url, dir.display());
+                roots.push(dir);
+            }
+            Err(e) => {
+                warn!(
+                    "recipe repo '{}' unreachable — skipping ({}); continuing with the reachable repos",
+                    repo.url, e
+                );
+            }
+        }
+    }
+    roots
+}
+
+/// Clone/pull the `Config.recipe_repos` entries into the default cache
+/// base (blocking git work off the async runtime). Best-effort: per-repo
+/// failures are warnings, never errors — returns the reachable roots.
+pub async fn sync_recipe_repos(config: &crate::config::Config) -> Vec<PathBuf> {
+    let repos = config.recipe_repos.clone();
+    let reg_path = registry_path();
+    let base = cache_base();
+    let registry = reg_path.as_deref().map(load_registry).unwrap_or_default();
+    tokio::task::spawn_blocking(move || {
+        let _ = std::fs::create_dir_all(&base);
+        sync_repos(&repos, &base, &registry, false)
+    })
+    .await
+    .unwrap_or_default()
+}
+
+/// Clone/pull ONE configured repo into its cache dir (hard failure —
+/// unlike the best-effort batch sync). Used by `promote` to make sure
+/// both checkouts exist before planning the move.
+pub fn sync_repo(repo: &RecipeRepo) -> Result<()> {
+    let base = cache_base();
+    let _ = std::fs::create_dir_all(&base);
+    let dir = repo_cache_dir(&base, &repo.url);
+    sync_one(&auth_url(repo), &dir, false)
+}
+
+/// Count the recipe dirs (`<root>/recipes/<name>/recipe.toml`) in a synced
+/// checkout — shown by `sm121 repos`.
+fn recipe_count(root: &Path) -> Option<usize> {
+    let dir = root.join("recipes");
+    let Ok(entries) = std::fs::read_dir(&dir) else {
+        return None;
+    };
+    Some(
+        entries
+            .flatten()
+            .filter(|e| e.path().join("recipe.toml").is_file())
+            .count(),
+    )
+}
+
+/// `sm121 repos` / `sm121 repos sync` / `sm121 archive` / `sm121 unarchive`
+/// dispatch (offline except `sync`, which shells out to git locally).
+pub async fn run(config: &crate::config::Config, cmd: &ReposCommands, dry_run: bool) -> Result<()> {
+    match cmd {
+        ReposCommands::List => {
+            if config.recipe_repos.is_empty() {
+                println!("no [[recipe_repos]] configured");
+                return Ok(());
+            }
+            let reg_path = registry_path().unwrap_or_else(|| PathBuf::from("repos.json"));
+            let reg = load_registry(&reg_path);
+            let base = cache_base();
+            println!("{:<52} {:<22} {:<18} recipes", "repo", "state", "last sync");
+            for repo in &config.recipe_repos {
+                let dir = repo_cache_dir(&base, &repo.url);
+                let state = if reg.is_archived(&repo.url) {
+                    "archived"
+                } else if dir.join(".git").is_dir() {
+                    "synced"
+                } else {
+                    "not cloned"
+                };
+                let last = std::fs::read_to_string(dir.join(LAST_SYNC_FILE))
+                    .ok()
+                    .map(|s| crate::stamp::fmt_ts(s.trim().parse().unwrap_or(0)))
+                    .unwrap_or_else(|| "—".to_string());
+                let n = recipe_count(&dir)
+                    .map(|n| n.to_string())
+                    .unwrap_or_else(|| "—".into());
+                println!("{:<52} {:<22} {:<18} {n}", repo.url, state, last);
+            }
+        }
+        ReposCommands::Sync { force } => {
+            let force = *force;
+            if dry_run {
+                for repo in &config.recipe_repos {
+                    let dir = repo_cache_dir(&cache_base(), &repo.url);
+                    println!(
+                        "[dry-run] would {} recipe repo {} → {}",
+                        if force { "re-clone" } else { "clone/pull" },
+                        repo.url,
+                        dir.display()
+                    );
+                }
+                return Ok(());
+            }
+            let reg_path = registry_path().unwrap_or_else(|| PathBuf::from("repos.json"));
+            let reg = load_registry(&reg_path);
+            let base = cache_base();
+            let repos = config.recipe_repos.clone();
+            let roots = tokio::task::spawn_blocking(move || sync_repos(&repos, &base, &reg, force))
+                .await
+                .unwrap_or_default();
+            if roots.is_empty() {
+                warn!("no recipe repos synced (all unreachable or archived)");
+            }
+        }
+    }
+    Ok(())
+}
+
+/// `sm121 archive <url>` / `sm121 unarchive <url>` — flip the entry in the
+/// `repos.json` registry (creates the file if missing).
+pub fn archive(url: &str, unarchive: bool, dry_run: bool) -> Result<()> {
+    let path = registry_path().ok_or_else(|| {
+        anyhow::anyhow!("cannot determine the global config dir (dirs::config_dir)")
+    })?;
+    if dry_run {
+        println!(
+            "[dry-run] would {} '{}' in {}",
+            if unarchive { "unarchive" } else { "archive" },
+            url,
+            path.display()
+        );
+        return Ok(());
+    }
+    if unarchive {
+        unarchive_at(&path, url)?;
+        println!("unarchived '{url}' in {}", path.display());
+    } else {
+        archive_at(&path, url)?;
+        println!("archived '{url}' in {}", path.display());
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+#[path = "recipe_repos_tests.rs"]
+mod tests;

+ 292 - 0
src/recipe_repos_tests.rs

@@ -0,0 +1,292 @@
+//! Tests for `recipe_repos` — hermetic `file://` git repos (no network).
+
+use super::{
+    LAST_SYNC_FILE, ReposRegistry, archive_at, auth_url, load_registry, save_registry, sync_one,
+    sync_repos, unarchive_at,
+};
+use crate::config::RecipeRepo;
+use std::path::PathBuf;
+
+fn git_ok(args: &[&str]) {
+    let out = std::process::Command::new("git")
+        .args(args)
+        .output()
+        .expect("run git");
+    assert!(
+        out.status.success(),
+        "git {args:?} failed: {}",
+        String::from_utf8_lossy(&out.stderr)
+    );
+}
+
+/// Create a temp git repo with `files` (`rel path -> content`), committed;
+/// returns (dir, `file://` url).
+fn make_repo(tag: &str, files: &[(&str, &str)]) -> (PathBuf, String) {
+    let dir = std::env::temp_dir().join(format!("sm121-rr-{tag}-{}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&dir);
+    std::fs::create_dir_all(&dir).unwrap();
+    for (rel, content) in files {
+        let p = dir.join(rel);
+        if let Some(parent) = p.parent() {
+            std::fs::create_dir_all(parent).unwrap();
+        }
+        std::fs::write(&p, content).unwrap();
+    }
+    let d = dir.display().to_string();
+    git_ok(&["-C", &d, "init", "-q"]);
+    git_ok(&["-C", &d, "config", "user.email", "t@sm121.test"]);
+    git_ok(&["-C", &d, "config", "user.name", "sm121-test"]);
+    git_ok(&["-C", &d, "add", "-A"]);
+    git_ok(&["-C", &d, "commit", "-qm", "init"]);
+    (dir, format!("file://{}", d))
+}
+
+fn repo(url: String) -> RecipeRepo {
+    RecipeRepo { url, token: None }
+}
+
+fn cache_base(tag: &str) -> PathBuf {
+    let p = std::env::temp_dir().join(format!("sm121-rr-cache-{tag}-{}", std::process::id()));
+    let _ = std::fs::remove_dir_all(&p);
+    p
+}
+
+#[test]
+fn clone_then_pull_picks_up_new_commits() {
+    let (_src_a, a_url) = make_repo(
+        "a",
+        &[
+            (
+                "recipes/alpha/recipe.toml",
+                "engine = \"vllm\"\nversion = \"v1\"\n",
+            ),
+            ("recipes/beta/repo-note.txt", "static\n"),
+        ],
+    );
+    let (src_b, b_url) = make_repo(
+        "b",
+        &[(
+            "recipes/beta/recipe.toml",
+            "engine = \"sglang\"\nversion = \"b1\"\n",
+        )],
+    );
+    let base = cache_base("pull");
+    let reg = ReposRegistry::default();
+
+    // first sync: clones both, in list order
+    let roots = sync_repos(
+        &[repo(a_url.clone()), repo(b_url.clone())],
+        &base,
+        &reg,
+        false,
+    );
+    assert_eq!(roots.len(), 2, "both repos synced: {roots:?}");
+    assert!(roots[0].join("recipes/alpha/recipe.toml").is_file());
+    assert!(roots[1].join("recipes/beta/recipe.toml").is_file());
+    assert!(roots[0].join(LAST_SYNC_FILE).is_file(), "last-sync stamped");
+
+    // new commit in B → the subsequent sync pulls it
+    let b2 = src_b.join("recipes/gamma/recipe.toml");
+    std::fs::create_dir_all(b2.parent().unwrap()).unwrap();
+    std::fs::write(&b2, "engine = \"vllm\"\n").unwrap();
+    let d = src_b.display().to_string();
+    git_ok(&["-C", &d, "add", "-A"]);
+    git_ok(&["-C", &d, "commit", "-qm", "gamma"]);
+    let roots = sync_repos(&[repo(a_url), repo(b_url)], &base, &reg, false);
+    assert!(
+        roots[1].join("recipes/gamma/recipe.toml").is_file(),
+        "pull landed gamma"
+    );
+    let _ = _src_a;
+}
+
+#[test]
+fn first_repo_shadows_later_on_name_collision() {
+    let (_a, a_url) = make_repo(
+        "shadow-a",
+        &[(
+            "recipes/dup/recipe.toml",
+            "engine = \"vllm\"\norigin = \"a\"\n",
+        )],
+    );
+    let (_b, b_url) = make_repo(
+        "shadow-b",
+        &[(
+            "recipes/dup/recipe.toml",
+            "engine = \"vllm\"\norigin = \"b\"\n",
+        )],
+    );
+    let base = cache_base("shadow");
+    let roots = sync_repos(
+        &[repo(a_url.clone()), repo(b_url.clone())],
+        &base,
+        &ReposRegistry::default(),
+        false,
+    );
+    assert_eq!(roots.len(), 2);
+    // roots land in recipe_sources as their `recipes/` dirs, in list order →
+    // the earlier (higher-priority) repo's copy is the one the search finds first
+    let cfg: crate::config::Config = toml::from_str(&format!(
+        "recipe_sources = [\"{}\", \"{}\"]\n",
+        roots[0].join("recipes").display(),
+        roots[1].join("recipes").display()
+    ))
+    .unwrap();
+    let found = crate::config::recipe_file("dup", &cfg.recipe_sources)
+        .unwrap_or_else(|| panic!("recipe 'dup' not found under the synced roots"));
+    assert!(
+        found.starts_with(roots[0].join("recipes").display().to_string().as_str()),
+        "first repo shadows the later one: {found:?}"
+    );
+}
+
+#[test]
+fn archived_repo_is_skipped() {
+    let (_a, a_url) = make_repo(
+        "arch-a",
+        &[("recipes/alpha/recipe.toml", "engine = \"vllm\"\n")],
+    );
+    let (_b, b_url) = make_repo(
+        "arch-b",
+        &[("recipes/beta/recipe.toml", "engine = \"sglang\"\n")],
+    );
+    let base = cache_base("arch");
+    let reg_path = base.join("repos.json");
+    archive_at(&reg_path, &a_url).unwrap();
+    let reg = load_registry(&reg_path);
+    assert!(reg.is_archived(&a_url));
+    let roots = sync_repos(
+        &[repo(a_url.clone()), repo(b_url.clone())],
+        &base,
+        &reg,
+        false,
+    );
+    assert_eq!(roots.len(), 1, "only the un-archived repo synced");
+    assert!(roots[0].join("recipes/beta/recipe.toml").is_file());
+    // unarchive → it syncs again
+    unarchive_at(&reg_path, &a_url).unwrap();
+    let reg = load_registry(&reg_path);
+    assert!(!reg.is_archived(&a_url));
+    let roots = sync_repos(&[repo(a_url), repo(b_url)], &base, &reg, false);
+    assert_eq!(roots.len(), 2);
+}
+
+#[test]
+fn unreachable_repo_is_skipped_not_fatal() {
+    let (_b, b_url) = make_repo(
+        "reach-b",
+        &[("recipes/beta/recipe.toml", "engine = \"sglang\"\n")],
+    );
+    let dead = format!(
+        "file://{}/no-such-sm121-repo-{}",
+        std::env::temp_dir().display(),
+        std::process::id()
+    );
+    let base = cache_base("reach");
+    let roots = sync_repos(
+        &[repo(dead), repo(b_url)],
+        &base,
+        &ReposRegistry::default(),
+        false,
+    );
+    assert_eq!(roots.len(), 1, "the live repo still syncs: {roots:?}");
+    assert!(roots[0].join("recipes/beta/recipe.toml").is_file());
+}
+
+#[test]
+fn force_reclones_from_scratch() {
+    let (_src, url) = make_repo(
+        "force",
+        &[("recipes/beta/recipe.toml", "engine = \"sglang\"\n")],
+    );
+    let base = cache_base("force");
+    let reg = ReposRegistry::default();
+    let roots = sync_repos(&[repo(url.clone())], &base, &reg, false);
+    std::fs::write(roots[0].join("junk.txt"), "x").unwrap();
+    let roots = sync_repos(&[repo(url)], &base, &reg, true);
+    assert!(
+        !roots[0].join("junk.txt").exists(),
+        "force re-clone dropped the local junk"
+    );
+    assert!(roots[0].join("recipes/beta/recipe.toml").is_file());
+}
+
+#[test]
+fn registry_missing_is_fresh() {
+    let p = cache_base("reg-missing").join("repos.json");
+    let reg = load_registry(&p);
+    assert!(reg.archived.is_empty());
+}
+
+#[test]
+fn registry_corrupt_is_backed_up_and_recreated() {
+    let p = cache_base("reg-bak").join("repos.json");
+    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
+    std::fs::write(&p, "{ not json").unwrap();
+    let reg = load_registry(&p);
+    assert!(
+        reg.archived.is_empty(),
+        "corrupt registry degrades to fresh"
+    );
+    assert!(
+        p.with_extension("json.bak").exists(),
+        "corrupt file preserved as .bak"
+    );
+    // and it is writable again
+    save_registry(&p, &reg).unwrap();
+}
+
+#[test]
+fn archive_unarchive_round_trip() {
+    let p = cache_base("reg-rt").join("repos.json");
+    archive_at(&p, "https://git.example/x.git").unwrap();
+    assert!(load_registry(&p).is_archived("https://git.example/x.git"));
+    // idempotent
+    archive_at(&p, "https://git.example/x.git").unwrap();
+    assert_eq!(load_registry(&p).archived.len(), 1);
+    unarchive_at(&p, "https://git.example/x.git").unwrap();
+    assert!(!load_registry(&p).is_archived("https://git.example/x.git"));
+    // unarchiving a never-archived url is a no-op
+    unarchive_at(&p, "https://git.example/y.git").unwrap();
+}
+
+#[test]
+fn token_is_embedded_only_for_http_urls() {
+    let https = RecipeRepo {
+        url: "https://git.kilork.org/sm121/recipes-production.git".into(),
+        token: Some("tok123".into()),
+    };
+    assert_eq!(
+        auth_url(&https),
+        "https://tok123@git.kilork.org/sm121/recipes-production.git"
+    );
+    let ssh = RecipeRepo {
+        url: "git@git.kilork.org:sm121/recipes.git".into(),
+        token: Some("ignored".into()),
+    };
+    assert_eq!(
+        auth_url(&ssh),
+        ssh.url,
+        "ssh urls pass through (the token is a no-op)"
+    );
+    let no_token = RecipeRepo {
+        url: "https://git.kilork.org/x.git".into(),
+        token: None,
+    };
+    assert_eq!(auth_url(&no_token), no_token.url);
+}
+
+#[test]
+fn sync_one_stamps_last_sync() {
+    let (_src, url) = make_repo("stamp", &[("recipes/beta/recipe.toml", "x = 1\n")]);
+    let base = cache_base("stamp");
+    let dir = super::repo_cache_dir(&base, &url);
+    sync_one(&url, &dir, false).unwrap();
+    let ts: i64 = std::fs::read_to_string(dir.join(LAST_SYNC_FILE))
+        .unwrap()
+        .trim()
+        .parse()
+        .unwrap();
+    let now = crate::stamp::now_secs();
+    assert!(ts > now - 60 && ts <= now, "stamp is a recent epoch: {ts}");
+}

+ 21 - 25
src/report/serve.rs

@@ -300,36 +300,32 @@ async fn report_file_or_stream(
     type SseRes = Result<SseEvent, std::convert::Infallible>;
     let stream = futures::stream::unfold(
         (config, recipe, file, 0usize),
-        |mut st: (Arc<Config>, String, String, usize)| {
-            async move {
-                let (cfg, recipe, file, last_len) = &mut st;
-                if let Some(content) = load_report(cfg, recipe, file) {
-                    let clean = crate::ansi::strip_ansi(content.as_bytes());
-                    let clean = String::from_utf8_lossy(&clean)
-                        .replace("\r\n", "\n")
-                        .replace('\r', "\n");
-                    let clean = clean_log(&clean);
-                    if clean.len() > *last_len {
-                        let delta = clean.split_at(*last_len).1.to_string();
-                        *last_len = clean.len();
-                        return Some((
-                            Ok(SseEvent::default().data(delta)) as SseRes,
-                            (cfg.clone(), recipe.clone(), file.clone(), *last_len),
-                        ));
-                    }
+        |mut st: (Arc<Config>, String, String, usize)| async move {
+            let (cfg, recipe, file, last_len) = &mut st;
+            if let Some(content) = load_report(cfg, recipe, file) {
+                let clean = crate::ansi::strip_ansi(content.as_bytes());
+                let clean = String::from_utf8_lossy(&clean)
+                    .replace("\r\n", "\n")
+                    .replace('\r', "\n");
+                let clean = clean_log(&clean);
+                if clean.len() > *last_len {
+                    let delta = clean.split_at(*last_len).1.to_string();
+                    *last_len = clean.len();
+                    return Some((
+                        Ok(SseEvent::default().data(delta)) as SseRes,
+                        (cfg.clone(), recipe.clone(), file.clone(), *last_len),
+                    ));
                 }
-                tokio::time::sleep(Duration::from_secs(2)).await;
-                Some((
-                    Ok(SseEvent::default().comment("keepalive")) as SseRes,
-                    (cfg.clone(), recipe.clone(), file.clone(), *last_len),
-                ))
             }
+            tokio::time::sleep(Duration::from_secs(2)).await;
+            Some((
+                Ok(SseEvent::default().comment("keepalive")) as SseRes,
+                (cfg.clone(), recipe.clone(), file.clone(), *last_len),
+            ))
         },
     );
     Sse::new(stream)
-        .keep_alive(
-            axum::response::sse::KeepAlive::new().interval(Duration::from_secs(15)),
-        )
+        .keep_alive(axum::response::sse::KeepAlive::new().interval(Duration::from_secs(15)))
         .into_response()
 }
 

+ 1 - 0
src/staging.rs

@@ -228,6 +228,7 @@ mod tests {
             drop_caches: true,
             mount_aliases: HashMap::new(),
             recipe_sources: Vec::new(),
+            recipe_repos: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
             registry: None,

+ 1 - 0
src/web/api.rs

@@ -69,6 +69,7 @@ pub(crate) async fn serve(
         sync_image: true,
         sync_image_source: None,
         no_push: false,
+        image_source: crate::docker::ImageSource::Repo,
         build_node: None,
         force_build: false,
         force: req.force,

+ 1 - 0
src/web/events.rs

@@ -235,6 +235,7 @@ mod tests {
             drop_caches: true,
             mount_aliases: std::collections::HashMap::new(),
             recipe_sources: Vec::new(),
+            recipe_repos: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
             registry: None,

+ 1 - 0
src/web/tests.rs

@@ -45,6 +45,7 @@ pub(crate) fn fake_config() -> Config {
         drop_caches: true,
         mount_aliases: HashMap::new(),
         recipe_sources: Vec::new(),
+        recipe_repos: Vec::new(),
         docker_image_sources: Vec::new(),
         cluster_sources: Vec::new(),
         registry: None,

+ 1 - 0
src/web/topo.rs

@@ -181,6 +181,7 @@ mod tests {
             drop_caches: true,
             mount_aliases: HashMap::new(),
             recipe_sources: Vec::new(),
+            recipe_repos: Vec::new(),
             docker_image_sources: Vec::new(),
             cluster_sources: Vec::new(),
             registry: None,