Bläddra i källkod

i18n: translate the recently added modules + drop dead FTL entries

The recipe-repos feature (recipe_repos.rs, promote.rs, registry_pull.rs)
and the drop_caches hook printed hardcoded English; convert every
user-facing message to fluent (info!/warn!/bail!/println! with t!), 51
new message IDs in all six locales (en/ru/es/it/fr/de), translations
keeping the fragments the tests assert on ('already exists', 'not a
configured', 'different repos').

- locale.rs: apply_global_locale() — spawn_blocking threads do not
  inherit the thread-local locale either; the repo-sync closure re-applies.
- registry/pull: pull-failure error now names the recipe (was name_of).
- FTL: remove 18 dead entries (verified unreferenced) from all locales.
Alexander Korolev 1 vecka sedan
förälder
incheckning
519a63ca71
11 ändrade filer med 578 tillägg och 217 borttagningar
  1. 67 18
      locales/de/main.ftl
  2. 67 18
      locales/en/main.ftl
  3. 67 18
      locales/es/main.ftl
  4. 67 18
      locales/fr/main.ftl
  5. 67 18
      locales/it/main.ftl
  6. 69 18
      locales/ru/main.ftl
  7. 5 2
      src/docker/mod.rs
  8. 28 22
      src/docker/registry_pull.rs
  9. 9 0
      src/locale.rs
  10. 64 49
      src/promote.rs
  11. 68 36
      src/recipe_repos.rs

+ 67 - 18
locales/de/main.ftl

@@ -9,11 +9,6 @@ container-log-header = 📜 Neueste Container-Logs:
 cluster-stopping = 🛑 Cluster '{ $name }' wird gestoppt...
 cluster-all-stopped = ✅ Alle Container gestoppt.
 cluster-section = === Cluster: { $name } ===
-container-count = Container gesamt: { $count }
-no-sm121-containers = (keine sm121-Container)
-docker-unavailable = ✗ Docker nicht verfügbar
-ssh-error = { $node } ({ $ip }): ✗ SSH-Fehler: { $error }
-task-join-error = ✗ Fehler beim Aufgaben-Join: { $error }
 doctor-header = 🩺 Doctor: { $name } ({ $engine }) [Backend: { $backend }]
 
 doctor-node-section = --- { $name } ---
@@ -24,10 +19,6 @@ doctor-repaired = ✅ Image repariert: { $tag }
 doctor-dry-build = (dry-run) Würde { $tag } auf { $node } bauen
 logs-follow-head = 📜 Logs für '{ $name }' (Head-Knoten) werden verfolgt...
 logs-section = --- { $name } ---
-stop-failed = Stop fehlgeschlagen auf { $name }: { $error }
-remove-failed = Remove fehlgeschlagen auf { $name }: { $error }
-failed-to = { $action } fehlgeschlagen auf { $name }: { $error }
-health-section = === Cluster: { $name } ===
 health-cluster-header = === { $name } === { $nodes } { $nodes ->
         [one] Knoten
        *[other] Knoten
@@ -46,9 +37,6 @@ health-soft-off = ⚙ { $list } aus (nicht blockierend)
 health-net-status =  [Netz: { $details }]
 health-no-containers = (keine Container)
 health-no-containers-inline = , keine Container
-health-total = Container gesamt: { $count }
-health-docker-unavailable = ✗ Docker nicht verfügbar
-docker-error-line = { $err }
 exec-header = ⚡ exec: { $cmd } ({ $nodes } { $nodes ->
         [one] Knoten
        *[other] Knoten
@@ -82,7 +70,6 @@ ha-invalid-entity = { $name }: ungültige Entity '{ $entity }': { $error }
 ha-call-failed = HA { $service } { $entity }: { $error }
 
 ha-skip-no-entity = ⚠ { $name }: keine `entity` konfiguriert, HA übersprungen
-ha-extra-invalid = { $label } ({ $extra }): ungültige Entity '{ $extra }'
 ha-extra-failed = HA { $service } { $extra } ({ $label }): { $error }
 
 power-switch-would-off = würde: HA turn_off { $entity }
@@ -91,7 +78,6 @@ power-switch-would-on = würde: HA turn_on { $entity }
 power-switch-would-on-extra = würde: HA turn_on { $extra } (zu Extras)
 power-switch-shutdown-failed = ✗ { $name } Herunterfahren: { $error }
 power-switch-waiting = ⏳ warte { $secs}s auf Herunterfahren
-ha-entity-error = { $name }: Entity '{ $entity }': { $error }
 power-switch-no-ha = keine [home_assistant]-Konfig; OS-Herunterfahren ausgeführt
 power-off-entity-would = würde: HA turn_off { $entity }
 pre-serve-skip = ⏭ Pre-Serve-Hooks übersprungen (--skip-before-serve)
@@ -108,7 +94,6 @@ cluster-switch-powered-on = ✅ Cluster-Schalter eingeschaltet
 cluster-waiting-for-nodes = ⏳ Warte, bis Knoten verfügbar sind...
 cluster-nodes-available = ✅ Alle Knoten sind jetzt verfügbar
 cluster-nodes-not-available-timeout = ❌ Timeout beim Warten auf Knoten-Verfügbarkeit
-ha-config-required-for-power-on = ❌ HA-Konfig für Einschalten erforderlich
 logs-header = 📜 Logs für Cluster '{ $name }' ({ $count } Knoten)
 engine-started = ✅ { $engine } auf { $name } gestartet (Rang { $rank })
 engine-start-failed = { $engine } auf { $name } (Rang { $rank }) konnte nicht gestartet werden: { $error }
@@ -125,7 +110,6 @@ exec-unknown-alias-list = Unbekannter Alias @{ $name }; bekannte Aliasse: { $ali
 mods-uploading = 📤 Lade Mods auf { $node } hoch...
 mods-uploaded = ✅ Mods auf { $node } hochgeladen
 mods-entrypoint-required = Rezept '{ $name }' hat Mods aber keinen Entrypoint; Entrypoint ist bei Mods erforderlich
-power-off-container-stop-failed = ⚠️ Container auf Knoten { $name } konnten nicht gestoppt werden: { $error}
 
 after-stop-failed = after_stop: Knoten { $name } fehlgeschlagen: { $error }
 after-stop-run = { $node } ({ $tag }) :: { $cmd }
@@ -202,7 +186,6 @@ tmux-composed-model-unsupported = Recipe '{ $name }' verwendet [composed_model],
 tmux-health-section = Tmux:
 tmux-health-entry-running = { $recipe } — { $node }: laufend (API: { $api })
 tmux-health-entry-degraded = { $recipe } — { $node }: degradiert (Sitzung existiert, Prozess tot)
-tmux-health-entry-stopped = { $recipe } — { $node }: gestoppt
 tmux-health-all-stopped = { $count ->
     [one] { $count } tmux-Rezept konfiguriert, alle gestoppt
    *[other] { $count } tmux-Rezepte konfiguriert, alle gestoppt
@@ -213,7 +196,6 @@ venv-preparing = 🔧 Bereite venv vor: { $venv }
 venv-exists = ✓ venv existiert bereits auf { $name }
 venv-creating = venv nicht gefunden auf { $name } — erstelle...
 venv-created = ✓ venv erfolgreich erstellt auf { $name }
-venv-prepare-failed = Venv-Vorbereitung: Knoten { $name } fehlgeschlagen: { $error }
 venv-not-created = venv wurde nicht erfolgreich auf { $name } erstellt — Setup-Befehle wurden ausgeführt, aber das activate-Skript fehlt
 
 # health render module (plain/md/html)
@@ -275,3 +257,70 @@ report-col-tps1 = Tok/s c1
 report-col-tps2 = Tok/s c2
 report-serving = Interaktiver Rezept-Report: { $url }  (Live-Updates; Ctrl-C zum Stoppen)
 report-title = Getestete Rezepte ({ $count })
+
+# --- recipe repos (src/recipe_repos.rs) ---
+git-failed = git { $cmd } fehlgeschlagen: { $err }
+recipe-repo-archived-skip = Rezept-Repo ist archiviert — überspringe: { $url }
+recipe-repo-synced = Rezept-Repo synchronisiert: { $url } → { $dir }
+recipe-repo-unreachable = Rezept-Repo '{ $url }' nicht erreichbar — überspringe ({ $err }); fahre mit den erreichbaren Repos fort
+repos-registry-corrupt = Repos-Registry '{ $path }' ist beschädigt ({ $err }) — verschiebe sie nach '{ $bak }' und beginne neu
+repos-registry-backup-failed = beschädigte Registry { $path } konnte nicht gesichert werden
+repos-none-configured = keine [[recipe_repos]] konfiguriert
+repos-col-repo = Repo
+repos-col-state = Status
+repos-col-last = letzte Synchronisierung
+repos-col-count = Rezepte
+repos-state-archived = archiviert
+repos-state-synced = synchronisiert
+repos-state-not-cloned = nicht geklont
+repos-sync-dry-reclone = [dry-run] würde das Rezept-Repo neu klonen { $url } → { $dir }
+repos-sync-dry-sync = [dry-run] würde das Rezept-Repo klonen/aktualisieren { $url } → { $dir }
+repos-none-synced = kein Rezept-Repo synchronisiert (alle unerreichbar oder archiviert)
+archive-config-dir-fail = globales Konfigurationsverzeichnis konnte nicht ermittelt werden (dirs::config_dir)
+archive-dry-archive = [dry-run] würde '{ $url }' archivieren in { $path }
+archive-dry-unarchive = [dry-run] würde '{ $url }' dearchivieren in { $path }
+archive-done = archiviert '{ $url }' in { $path }
+unarchive-done = dearchiviert '{ $url }' in { $path }
+
+# --- promote (src/promote.rs) ---
+promote-repo-not-configured = Repo '{ $url }' ist kein konfigurierter [[recipe_repos]]-Eintrag — füge es der Config hinzu (das Promote braucht sein Token für den Push)
+promote-checkout-missing = Repo '{ $url }' ist nicht synchronisiert — führe `sm121 repos sync` aus (dry-run: kein git-I/O)
+promote-checkout-sync-fail = Repo '{ $url }' ist nicht synchronisiert — zuerst synchronisieren (sm121 repos sync)
+promote-detached-head = Checkout { $path } ist im detached HEAD — vorher einen Branch auschecken
+promote-same-repo = --from und --to müssen unterschiedliche Repos sein
+promote-recipe-not-in-source = Rezept '{ $recipe }' im Checkout des Quell-Repos nicht gefunden ({ $path }) — ist der Checkout aktuell? (sm121 repos sync)
+promote-recipe-in-target = Rezept '{ $recipe }' existiert bereits im Ziel-Repo — Überschreiben verweigert (dort zuerst löschen)
+promote-cp-failed = cp fehlgeschlagen: { $err }
+promote-cp-context = cp ausführen
+promote-target-dir-context = recipes/-Verzeichnis des Ziels
+promote-run-start = promote '{ $recipe }' von { $from } nach { $to } ({ $count } { $count ->
+        [one] Schritt
+       *[other] Schritte
+    })
+promote-source-checkout = Quell-Checkout: { $path }
+promote-target-checkout = Ziel-Checkout: { $path }
+promote-step-line = [{ $side }] { $description }
+promote-done = promoted '{ $recipe }' — das Ziel hat es jetzt; die nächste Synchronisierung beider Repos festigt den Umzug
+promote-dry-head = [dry-run] promote '{ $recipe }' von { $from } nach { $to }:
+promote-dry-suppressed = (unterdrückt: -d dry-run hat Vorrang vor --apply)
+promote-run-apply-hint = mit --apply erneut ausführen
+
+# --- registry image source (src/docker/registry_pull.rs) ---
+registry-needs-registry = --image-source registry erfordert eine [registry]-Sektion in der Config
+registry-ref-map-fail = Image '{ $image }' konnte keinem Registry-Ref unter Base '{ $base }' zugeordnet werden (der Image-Name muss ein schlichtes <name>[:tag] sein)
+registry-pull-context = Registry-Pull auf '{ $node }'
+registry-present = Registry-Image '{ $image }' ist auf '{ $node }' bereits vorhanden (Checksum-Label stimmt überein)
+registry-pulled = gezogen '{ $ref }' → '{ $image }' auf '{ $node }' (Checksum-Label verifiziert)
+registry-dry-run = [dry-run] --image-source registry: würde { $ref } ziehen → Tag '{ $image }' auf { $count } { $count ->
+        [one] Knoten
+       *[other] Knoten
+    } (Checksum { $checksum }…)
+registry-tag-not-found = { $node }: Tag '{ $ref }' ist nicht in der Registry — erst build + push (sm121 build --recipe { $recipe } --build-node <node>) oder zurück auf --image-source repo
+registry-tag-failed = { $node }: docker tag nach dem Pull fehlgeschlagen
+registry-label-mismatch = { $node }: das sm121.checksum-Label des gezogenen Images ist '{ $got }', erwartet '{ $checksum }' — der Registry-Tag trägt diesen Build nicht
+registry-output-unparseable = { $node }: nicht auswertbare Pull-Ausgabe: { $out }
+registry-pull-failed = --image-source registry fehlgeschlagen auf: { $failed }
+
+# --- drop_caches hook (src/docker/mod.rs) ---
+drop-caches-start = Leere den Page-Cache auf allen Knoten...
+drop-caches-failed = drop_caches auf { $node } fehlgeschlagen: { $err }

+ 67 - 18
locales/en/main.ftl

@@ -9,11 +9,6 @@ container-log-header = 📜 Latest container logs:
 cluster-stopping = 🛑 Stopping cluster '{ $name }'...
 cluster-all-stopped = ✅ All containers stopped.
 cluster-section = === Cluster: { $name } ===
-container-count = Total containers: { $count }
-no-sm121-containers = (no sm121 containers)
-docker-unavailable = ✗ Docker unavailable
-ssh-error = { $node } ({ $ip }): ✗ SSH error: { $error }
-task-join-error = ✗ Task join error: { $error }
 doctor-header = 🩺 Doctor: { $name } ({ $engine }) [backend: { $backend }]
 doctor-node-section = --- { $name } ---
 doctor-all-passed = ✅ All checks passed!
@@ -23,10 +18,6 @@ doctor-repaired = ✅ Repaired image: { $tag }
 doctor-dry-build = (dry-run) Would build { $tag } on { $node }
 logs-follow-head = 📜 Following logs for '{ $name }' (head node)...
 logs-section = --- { $name } ---
-stop-failed = Stop failed on { $name }: { $error }
-remove-failed = Remove failed on { $name }: { $error }
-failed-to = Failed to { $action } on { $name }: { $error }
-health-section = === Cluster: { $name } ===
 health-cluster-header = === { $name } === { $nodes } { $nodes ->
         [one] node
        *[other] nodes
@@ -42,9 +33,6 @@ health-soft-off = ⚙ { $list } off (non-blocking)
 health-net-status =  [net: { $details }]
 health-no-containers = (no containers)
 health-no-containers-inline = , no containers
-health-total = Total containers: { $count }
-health-docker-unavailable = ✗ Docker unavailable
-docker-error-line = { $err }
 exec-header = ⚡ exec: { $cmd } ({ $nodes } { $nodes ->
         [one] node
        *[other] nodes
@@ -74,7 +62,6 @@ power-on-head = 💡 power on: { $nodes } node(s)
 ha-invalid-entity = { $name }: invalid entity '{ $entity }': { $error }
 ha-call-failed = HA { $service } { $entity }: { $error }
 ha-skip-no-entity = ⚠ { $name }: no `entity` configured, skipping HA
-ha-extra-invalid = { $label } ({ $extra }): invalid entity '{ $extra }'
 ha-extra-failed = HA { $service } { $extra } ({ $label }): { $error }
 power-switch-would-off = would: HA turn_off { $entity }
 power-switch-would-off-extra = would: HA turn_off { $extra } (from extras)
@@ -82,7 +69,6 @@ power-switch-would-on = would: HA turn_on { $entity }
 power-switch-would-on-extra = would: HA turn_on { $extra } (to extras)
 power-switch-shutdown-failed = ✗ { $name } shutdown: { $error }
 power-switch-waiting = ⏳ waiting { $secs }s for shutdown
-ha-entity-error = { $name }: entity '{ $entity }': { $error }
 power-switch-no-ha = no [home_assistant] config; OS shutdown was performed
 power-off-entity-would = would: HA turn_off { $entity }
 pre-serve-skip = ⏭ Skipping pre-serve hooks (--skip-before-serve)
@@ -99,7 +85,6 @@ cluster-switch-powered-on = ✅ Cluster switch powered on
 cluster-waiting-for-nodes = ⏳ Waiting for nodes to become available...
 cluster-nodes-available = ✅ All nodes are now available
 cluster-nodes-not-available-timeout = ❌ Timeout waiting for nodes to become available
-ha-config-required-for-power-on = ❌ HA config required for power-on
 logs-header = 📜 Logs for cluster '{ $name }' ({ $count } nodes)
 engine-started = ✅ { $engine } started on { $name } (rank { $rank })
 engine-start-failed = Failed to start { $engine } on { $name } (rank { $rank }): { $error }
@@ -116,7 +101,6 @@ exec-unknown-alias-list = Unknown alias @{ $name }; known aliases: { $aliases }
 mods-uploading = 📤 Uploading mods to { $node }...
 mods-uploaded = ✅ Mods uploaded to { $node }
 mods-entrypoint-required = Recipe '{ $name }' has mods but no entrypoint; entrypoint is required when mods are present
-power-off-container-stop-failed = ⚠️ Failed to stop containers on node { $name }: { $error}
 
 after-stop-failed = after_stop: node { $name } failed: { $error }
 after-stop-run = { $node } ({ $tag }) :: { $cmd }
@@ -169,7 +153,6 @@ tmux-composed-model-unsupported = Recipe '{ $name }' uses [composed_model], whic
 tmux-health-section = Tmux:
 tmux-health-entry-running = { $recipe } — { $node }: running (API: { $api })
 tmux-health-entry-degraded = { $recipe } — { $node }: degraded (session exists, process dead)
-tmux-health-entry-stopped = { $recipe } — { $node }: stopped
 tmux-health-all-stopped = { $count ->
     [one] { $count } tmux recipe configured, all stopped
    *[other] { $count } tmux recipes configured, all stopped
@@ -180,7 +163,6 @@ venv-preparing = 🔧 Preparing venv: { $venv }
 venv-exists = ✓ venv already exists on { $name }
 venv-creating = venv not found on { $name } — creating...
 venv-created = ✓ venv created successfully on { $name }
-venv-prepare-failed = venv prepare: node { $name } failed: { $error }
 venv-not-created = venv was not created successfully on { $name } — setup commands ran but activate script is missing
 composed-dir-dry-run = [dry-run] would build composed model dir '{ $host }' on '{ $node }' (mount: { $mount }, { $sources } { $sources ->
         [one] source
@@ -256,3 +238,70 @@ power-on-skip = { $name }: no HA entity — skipped
 power-on-failed = { $name }: HA call failed: { $error }
 power-on-cycling = ⟳ { $name }: plug reads on but the node is down — cycling { $entity }
 power-on-off-wait-timeout = { $name }: plug did not report off within 10 s (relay may have stuck)
+
+# --- recipe repos (src/recipe_repos.rs) ---
+git-failed = git { $cmd } failed: { $err }
+recipe-repo-archived-skip = recipe repo archived — skipping: { $url }
+recipe-repo-synced = recipe repo synced: { $url } → { $dir }
+recipe-repo-unreachable = recipe repo '{ $url }' unreachable — skipping ({ $err }); continuing with the reachable repos
+repos-registry-corrupt = repos registry '{ $path }' is corrupt ({ $err }) — moving it to '{ $bak }' and starting fresh
+repos-registry-backup-failed = could not back up corrupt registry { $path }
+repos-none-configured = no [[recipe_repos]] configured
+repos-col-repo = repo
+repos-col-state = state
+repos-col-last = last sync
+repos-col-count = recipes
+repos-state-archived = archived
+repos-state-synced = synced
+repos-state-not-cloned = not cloned
+repos-sync-dry-reclone = [dry-run] would re-clone recipe repo { $url } → { $dir }
+repos-sync-dry-sync = [dry-run] would clone/pull recipe repo { $url } → { $dir }
+repos-none-synced = no recipe repos synced (all unreachable or archived)
+archive-config-dir-fail = cannot determine the global config dir (dirs::config_dir)
+archive-dry-archive = [dry-run] would archive '{ $url }' in { $path }
+archive-dry-unarchive = [dry-run] would unarchive '{ $url }' in { $path }
+archive-done = archived '{ $url }' in { $path }
+unarchive-done = unarchived '{ $url }' in { $path }
+
+# --- promote (src/promote.rs) ---
+promote-repo-not-configured = repo '{ $url }' is not a configured [[recipe_repos]] entry — add it to the config (the promote needs its token for the push)
+promote-checkout-missing = repo '{ $url }' is not synced — run `sm121 repos sync` (dry-run: no git I/O)
+promote-checkout-sync-fail = repo '{ $url }' is not synced — sync it first (sm121 repos sync)
+promote-detached-head = checkout { $path } is in detached HEAD — check out a branch first
+promote-same-repo = --from and --to must be different repos
+promote-recipe-not-in-source = recipe '{ $recipe }' not found in the source repo's checkout ({ $path }) — is the checkout current? (sm121 repos sync)
+promote-recipe-in-target = recipe '{ $recipe }' already exists in the target repo — refusing to overwrite (remove it there first)
+promote-cp-failed = cp failed: { $err }
+promote-cp-context = running cp
+promote-target-dir-context = target recipes/ dir
+promote-run-start = promoting '{ $recipe }' from { $from } to { $to } ({ $count } { $count ->
+        [one] step
+       *[other] steps
+    })
+promote-source-checkout = source checkout: { $path }
+promote-target-checkout = target checkout: { $path }
+promote-step-line = [{ $side }] { $description }
+promote-done = promoted '{ $recipe }' — the target now has it; the next sync of both repos keeps the move
+promote-dry-head = [dry-run] promote '{ $recipe }' from { $from } to { $to }:
+promote-dry-suppressed = (suppressed: -d dry-run wins over --apply)
+promote-run-apply-hint = re-run with --apply to execute
+
+# --- registry image source (src/docker/registry_pull.rs) ---
+registry-needs-registry = --image-source registry requires a [registry] section in the config
+registry-ref-map-fail = cannot map image '{ $image }' to a registry ref under base '{ $base }' (the image name must be a bare <name>[:tag])
+registry-pull-context = registry pull on '{ $node }'
+registry-present = registry image '{ $image }' already present on '{ $node }' (checksum label matches)
+registry-pulled = pulled '{ $ref }' → '{ $image }' on '{ $node }' (checksum label verified)
+registry-dry-run = [dry-run] --image-source registry: pull { $ref } → tag '{ $image }' on { $count } { $count ->
+        [one] node
+       *[other] nodes
+    } (checksum { $checksum }…)
+registry-tag-not-found = { $node }: tag '{ $ref }' not found in the registry — build + push it first (sm121 build --recipe { $recipe } --build-node <node>), or fall back to --image-source repo
+registry-tag-failed = { $node }: docker tag failed after the pull
+registry-label-mismatch = { $node }: the pulled image's sm121.checksum label is '{ $got }', expected '{ $checksum }' — the registry tag does not carry this build
+registry-output-unparseable = { $node }: unparseable pull output: { $out }
+registry-pull-failed = --image-source registry failed on: { $failed }
+
+# --- drop_caches hook (src/docker/mod.rs) ---
+drop-caches-start = Dropping page cache on all nodes...
+drop-caches-failed = drop_caches on { $node } failed: { $err }

+ 67 - 18
locales/es/main.ftl

@@ -9,11 +9,6 @@ container-log-header = 📜 Logs de contenedor más recientes:
 cluster-stopping = 🛑 Deteniendo cluster '{ $name }'...
 cluster-all-stopped = ✅ Todos los contenedores detenidos.
 cluster-section = === Cluster: { $name } ===
-container-count = Contenedores totales: { $count }
-no-sm121-containers = (sin contenedores sm121)
-docker-unavailable = ✗ Docker no disponible
-ssh-error = { $node } ({ $ip }): ✗ Error SSH: { $error }
-task-join-error = ✗ Error de unión de tarea: { $error }
 doctor-header = 🩺 Doctor: { $name } ({ $engine }) [backend: { $backend }]
 
 doctor-node-section = --- { $name } ---
@@ -24,10 +19,6 @@ doctor-repaired = ✅ Imagen reparada: { $tag }
 doctor-dry-build = (dry-run) Se construiría { $tag } en { $node }
 logs-follow-head = 📜 Siguiendo logs de '{ $name }' (nodo principal)...
 logs-section = --- { $name } ---
-stop-failed = Detención falló en { $name }: { $error }
-remove-failed = Eliminación falló en { $name }: { $error }
-failed-to = Falló { $action } en { $name }: { $error }
-health-section = === Cluster: { $name } ===
 health-cluster-header = === { $name } === { $nodes } { $nodes ->
         [one] nodo
        *[other] nodos
@@ -46,9 +37,6 @@ health-soft-off = ⚙ { $list } apagado (no bloqueante)
 health-net-status =  [red: { $details }]
 health-no-containers = (sin contenedores)
 health-no-containers-inline = , sin contenedores
-health-total = Contenedores totales: { $count }
-health-docker-unavailable = ✗ Docker no disponible
-docker-error-line = { $err }
 exec-header = ⚡ exec: { $cmd } ({ $nodes } { $nodes ->
         [one] nodo
        *[other] nodos
@@ -82,7 +70,6 @@ ha-invalid-entity = { $name }: entity inválido '{ $entity }': { $error }
 ha-call-failed = HA { $service } { $entity }: { $error }
 
 ha-skip-no-entity = ⚠ { $name }: sin `entity` configurado, omitiendo HA
-ha-extra-invalid = { $label } ({ $extra }): entity inválido '{ $extra }'
 ha-extra-failed = HA { $service } { $extra } ({ $label }): { $error }
 
 power-switch-would-off = sería: HA turn_off { $entity }
@@ -91,7 +78,6 @@ power-switch-would-on = sería: HA turn_on { $entity }
 power-switch-would-on-extra = sería: HA turn_on { $extra } (a extras)
 power-switch-shutdown-failed = ✗ { $name } apagado: { $error }
 power-switch-waiting = ⏳ esperando { $secs}s para apagado
-ha-entity-error = { $name }: entidad '{ $entity }': { $error }
 power-switch-no-ha = sin config [home_assistant]; el apagado de SO se realizó
 power-off-entity-would = sería: HA turn_off { $entity }
 pre-serve-skip = ⏭ Omitiendo hooks pre-serve (--skip-before-serve)
@@ -108,7 +94,6 @@ cluster-switch-powered-on = ✅ Switch del cluster encendido
 cluster-waiting-for-nodes = ⏳ Esperando que los nodos estén disponibles...
 cluster-nodes-available = ✅ Todos los nodos ya están disponibles
 cluster-nodes-not-available-timeout = ❌ Tiempo de espera agotado para nodos disponibles
-ha-config-required-for-power-on = ❌ Config HA requerida para encender
 logs-header = 📜 Logs del cluster '{ $name }' ({ $count } nodos)
 engine-started = ✅ { $engine } iniciado en { $name } (rango { $rank })
 engine-start-failed = Falló iniciar { $engine } en { $name } (rango { $rank }): { $error }
@@ -125,7 +110,6 @@ exec-unknown-alias-list = Alias desconocido @{ $name }; alias conocidos: { $alia
 mods-uploading = 📤 Subiendo mods a { $node }...
 mods-uploaded = ✅ Mods subidos a { $node }
 mods-entrypoint-required = La receta '{ $name }' tiene mods pero no tiene entrypoint; se requiere entrypoint cuando hay mods
-power-off-container-stop-failed = ⚠️ No se pudieron detener contenedores en el nodo { $name }: { $error}
 
 after-stop-failed = after_stop: nodo { $name } falló: { $error }
 after-stop-run = { $node } ({ $tag }) :: { $cmd }
@@ -202,7 +186,6 @@ tmux-composed-model-unsupported = Recipe '{ $name }' usa [composed_model], que e
 tmux-health-section = Tmux:
 tmux-health-entry-running = { $recipe } — { $node }: en ejecución (API: { $api })
 tmux-health-entry-degraded = { $recipe } — { $node }: degradado (la sesión existe, proceso muerto)
-tmux-health-entry-stopped = { $recipe } — { $node }: detenido
 tmux-health-all-stopped = { $count ->
     [one] { $count } receta de tmux configurada, todas detenidas
    *[other] { $count } recetas de tmux configuradas, todas detenidas
@@ -213,7 +196,6 @@ venv-preparing = 🔧 Preparando venv: { $venv }
 venv-exists = ✓ venv ya existe en { $name }
 venv-creating = venv no encontrado en { $name } — creando...
 venv-created = ✓ venv creado correctamente en { $name }
-venv-prepare-failed = preparación venv: nodo { $name } falló: { $error }
 venv-not-created = venv no se creó correctamente en { $name } — los comandos de configuración se ejecutaron pero falta el script activate
 
 # health render module (plain/md/html)
@@ -275,3 +257,70 @@ report-col-tps1 = Tok/s c1
 report-col-tps2 = Tok/s c2
 report-serving = Informe interactivo de recetas: { $url }  (actualizaciones en vivo; Ctrl-C para parar)
 report-title = Recetas probadas ({ $count })
+
+# --- recipe repos (src/recipe_repos.rs) ---
+git-failed = git { $cmd } falló: { $err }
+recipe-repo-archived-skip = repositorio de recetas archivado — omitiendo: { $url }
+recipe-repo-synced = repositorio de recetas sincronizado: { $url } → { $dir }
+recipe-repo-unreachable = repositorio de recetas '{ $url }' inaccesible — omitiendo ({ $err }); continuando con los repositorios accesibles
+repos-registry-corrupt = el registro de repositorios '{ $path }' está corrupto ({ $err }) — moviéndolo a '{ $bak }' y empezando de nuevo
+repos-registry-backup-failed = no se pudo respaldar el registro corrupto { $path }
+repos-none-configured = no hay [[recipe_repos]] configurados
+repos-col-repo = repositorio
+repos-col-state = estado
+repos-col-last = última sincronización
+repos-col-count = recetas
+repos-state-archived = archivado
+repos-state-synced = sincronizado
+repos-state-not-cloned = sin clonar
+repos-sync-dry-reclone = [dry-run] volvería a clonar el repositorio de recetas { $url } → { $dir }
+repos-sync-dry-sync = [dry-run] clonaría/actualizaría el repositorio de recetas { $url } → { $dir }
+repos-none-synced = ningún repositorio de recetas sincronizado (todos inaccesibles o archivados)
+archive-config-dir-fail = no se pudo determinar el directorio de configuración global (dirs::config_dir)
+archive-dry-archive = [dry-run] archivaría '{ $url }' en { $path }
+archive-dry-unarchive = [dry-run] desarchivaría '{ $url }' en { $path }
+archive-done = archivado '{ $url }' en { $path }
+unarchive-done = desarchivado '{ $url }' en { $path }
+
+# --- promote (src/promote.rs) ---
+promote-repo-not-configured = el repositorio '{ $url }' no es una entrada [[recipe_repos]] configurada — añádelo a la configuración (el promote necesita su token para el push)
+promote-checkout-missing = el repositorio '{ $url }' no está sincronizado — ejecuta `sm121 repos sync` (dry-run: sin I/O de git)
+promote-checkout-sync-fail = el repositorio '{ $url }' no está sincronizado — sincronízalo primero (sm121 repos sync)
+promote-detached-head = el checkout { $path } está en detached HEAD — cambia a una rama primero
+promote-same-repo = --from y --to deben ser repositorios distintos
+promote-recipe-not-in-source = la receta '{ $recipe }' no se encontró en el checkout del repositorio de origen ({ $path }) — ¿está actualizado el checkout? (sm121 repos sync)
+promote-recipe-in-target = la receta '{ $recipe }' ya existe en el repositorio de destino — no se sobrescribe (elimínala allí primero)
+promote-cp-failed = cp falló: { $err }
+promote-cp-context = ejecutando cp
+promote-target-dir-context = directorio recipes/ del destino
+promote-run-start = promoviendo '{ $recipe }' de { $from } a { $to } ({ $count } { $count ->
+        [one] paso
+       *[other] pasos
+    })
+promote-source-checkout = checkout de origen: { $path }
+promote-target-checkout = checkout de destino: { $path }
+promote-step-line = [{ $side }] { $description }
+promote-done = promovida '{ $recipe }' — el destino ya la tiene; la próxima sincronización de ambos repositorios consolida el movimiento
+promote-dry-head = [dry-run] promovería '{ $recipe }' de { $from } a { $to }:
+promote-dry-suppressed = (suprimido: -d dry-run tiene prioridad sobre --apply)
+promote-run-apply-hint = vuelve a ejecutar con --apply para aplicarlo
+
+# --- registry image source (src/docker/registry_pull.rs) ---
+registry-needs-registry = --image-source registry requiere una sección [registry] en la configuración
+registry-ref-map-fail = no se pudo mapear la imagen '{ $image }' a un ref del registro bajo base '{ $base }' (el nombre de la imagen debe ser un <name>[:tag] simple)
+registry-pull-context = pull del registro en '{ $node }'
+registry-present = la imagen '{ $image }' del registro ya está en '{ $node }' (la etiqueta checksum coincide)
+registry-pulled = descargada '{ $ref }' → '{ $image }' en '{ $node }' (etiqueta checksum verificada)
+registry-dry-run = [dry-run] --image-source registry: descargaría { $ref } → etiqueta '{ $image }' en { $count } { $count ->
+        [one] nodo
+       *[other] nodos
+    } (checksum { $checksum }…)
+registry-tag-not-found = { $node }: el tag '{ $ref }' no está en el registro — compílalo y haz push primero (sm121 build --recipe { $recipe } --build-node <node>), o vuelve a --image-source repo
+registry-tag-failed = { $node }: docker tag falló tras la descarga
+registry-label-mismatch = { $node }: la etiqueta sm121.checksum de la imagen descargada es '{ $got }', se esperaba '{ $checksum }' — el tag del registro no lleva esta build
+registry-output-unparseable = { $node }: salida de la descarga ilegible: { $out }
+registry-pull-failed = --image-source registry falló en: { $failed }
+
+# --- drop_caches hook (src/docker/mod.rs) ---
+drop-caches-start = Vaciando la caché de páginas en todos los nodos...
+drop-caches-failed = drop_caches en { $node } falló: { $err }

+ 67 - 18
locales/fr/main.ftl

@@ -9,11 +9,6 @@ container-log-header = 📜 Logs de conteneur les plus récents :
 cluster-stopping = 🛑 Arrêt du cluster '{ $name }'...
 cluster-all-stopped = ✅ Tous les conteneurs arrêtés.
 cluster-section = === Cluster : { $name } ===
-container-count = Conteneurs totaux : { $count }
-no-sm121-containers = (aucun conteneur sm121)
-docker-unavailable = ✗ Docker indisponible
-ssh-error = { $node } ({ $ip }) : ✗ Erreur SSH : { $error }
-task-join-error = ✗ Erreur de jointure de tâche : { $error }
 doctor-header = 🩺 Doctor: { $name } ({ $engine }) [backend : { $backend }]
 
 doctor-node-section = --- { $name } ---
@@ -24,10 +19,6 @@ doctor-repaired = ✅ Image réparée : { $tag }
 doctor-dry-build = (dry-run) Construirait { $tag } sur { $node }
 logs-follow-head = 📜 Suivi des logs de '{ $name }' (nœud head)...
 logs-section = --- { $name } ---
-stop-failed = Échec de l'arrêt sur { $name } : { $error }
-remove-failed = Échec de la suppression sur { $name } : { $error }
-failed-to = Échec de { $action } sur { $name } : { $error }
-health-section = === Cluster : { $name } ===
 health-cluster-header = === { $name } === { $nodes } { $nodes ->
         [one] nœud
        *[other] nœuds
@@ -46,9 +37,6 @@ health-soft-off = ⚙ { $list } éteint (non bloquant)
 health-net-status =  [réseau : { $details }]
 health-no-containers = (aucun conteneur)
 health-no-containers-inline = , aucun conteneur
-health-total = Conteneurs totaux : { $count }
-health-docker-unavailable = ✗ Docker indisponible
-docker-error-line = { $err }
 exec-header = ⚡ exec : { $cmd } ({ $nodes } { $nodes ->
         [one] nœud
        *[other] nœuds
@@ -82,7 +70,6 @@ ha-invalid-entity = { $name } : entity invalide '{ $entity }' : { $error }
 ha-call-failed = HA { $service } { $entity } : { $error }
 
 ha-skip-no-entity = ⚠ { $name } : pas d'`entity` configurée, HA ignoré
-ha-extra-invalid = { $label } ({ $extra }) : entity invalide '{ $extra }'
 ha-extra-failed = HA { $service } { $extra } ({ $label }) : { $error }
 
 power-switch-would-off = serait : HA turn_off { $entity }
@@ -91,7 +78,6 @@ power-switch-would-on = serait : HA turn_on { $entity }
 power-switch-would-on-extra = serait : HA turn_on { $extra } (vers extras)
 power-switch-shutdown-failed = ✗ { $name } extinction : { $error }
 power-switch-waiting = ⏳ attente { $secs}s pour l'extinction
-ha-entity-error = { $name } : entité '{ $entity }' : { $error }
 power-switch-no-ha = pas de config [home_assistant] ; l'extinction OS a été effectuée
 power-off-entity-would = serait : HA turn_off { $entity }
 pre-serve-skip = ⏭ Hooks pre-serve ignorés (--skip-before-serve)
@@ -108,7 +94,6 @@ cluster-switch-powered-on = ✅ Interrupteur du cluster allumé
 cluster-waiting-for-nodes = ⏳ En attente de la disponibilité des nœuds...
 cluster-nodes-available = ✅ Tous les nœuds sont maintenant disponibles
 cluster-nodes-not-available-timeout = ❌ Délai dépassé en attente des nœuds
-ha-config-required-for-power-on = ❌ Config HA requise pour l'allumage
 logs-header = 📜 Logs du cluster '{ $name }' ({ $count } nœuds)
 engine-started = ✅ { $engine } démarré sur { $name } (rang { $rank })
 engine-start-failed = Échec du démarrage de { $engine } sur { $name } (rang { $rank }) : { $error }
@@ -125,7 +110,6 @@ exec-unknown-alias-list = Alias inconnu @{ $name } ; alias connus : { $aliases }
 mods-uploading = 📤 Téléchargement des mods vers { $node }...
 mods-uploaded = ✅ Mods téléchargés vers { $node }
 mods-entrypoint-required = La recette '{ $name }' a des mods mais pas d'entrypoint ; l'entrypoint est requis quand des mods sont présents
-power-off-container-stop-failed = ⚠️ Échec de l'arrêt des conteneurs sur le nœud { $name } : { $error}
 
 after-stop-failed = after_stop : nœud { $name } échec : { $error }
 after-stop-run = { $node } ({ $tag }) :: { $cmd }
@@ -202,7 +186,6 @@ tmux-composed-model-unsupported = Recipe '{ $name }' utilise [composed_model], c
 tmux-health-section = Tmux:
 tmux-health-entry-running = { $recipe } — { $node }: en cours (API : { $api })
 tmux-health-entry-degraded = { $recipe } — { $node }: dégradé (la session existe, processus mort)
-tmux-health-entry-stopped = { $recipe } — { $node }: arrêté
 tmux-health-all-stopped = { $count ->
     [one] { $count } recette tmux configurée, toutes arrêtées
    *[other] { $count } recettes tmux configurées, toutes arrêtées
@@ -213,7 +196,6 @@ venv-preparing = 🔧 Préparation du venv : { $venv }
 venv-exists = ✓ venv existe déjà sur { $name }
 venv-creating = venv non trouvé sur { $name } — création...
 venv-created = ✓ venv créé avec succès sur { $name }
-venv-prepare-failed = préparation venv : nœud { $name } échec : { $error }
 venv-not-created = venv n'a pas été créé avec succès sur { $name } — les commandes de configuration ont été exécutées mais le script activate est manquant
 
 # health render module (plain/md/html)
@@ -275,3 +257,70 @@ report-col-tps1 = Tok/s c1
 report-col-tps2 = Tok/s c2
 report-serving = Rapport interactif des recettes : { $url }  (mises à jour en direct ; Ctrl-C pour arrêter)
 report-title = Recettes testées ({ $count })
+
+# --- recipe repos (src/recipe_repos.rs) ---
+git-failed = git { $cmd } a échoué : { $err }
+recipe-repo-archived-skip = dépôt de recettes archivé — ignoré : { $url }
+recipe-repo-synced = dépôt de recettes synchronisé : { $url } → { $dir }
+recipe-repo-unreachable = dépôt de recettes '{ $url }' injoignable — ignoré ({ $err}) ; poursuite avec les dépôts joignables
+repos-registry-corrupt = le registre des dépôts '{ $path }' est corrompu ({ $err }) — déplacé vers '{ $bak }' et repart de zéro
+repos-registry-backup-failed = impossible de sauvegarder le registre corrompu { $path }
+repos-none-configured = aucun [[recipe_repos]] configuré
+repos-col-repo = dépôt
+repos-col-state = état
+repos-col-last = dernière synchro
+repos-col-count = recettes
+repos-state-archived = archivé
+repos-state-synced = synchronisé
+repos-state-not-cloned = non cloné
+repos-sync-dry-reclone = [dry-run] re-clonerait le dépôt de recettes { $url } → { $dir }
+repos-sync-dry-sync = [dry-run] clonerait/mettrait à jour le dépôt de recettes { $url } → { $dir }
+repos-none-synced = aucun dépôt de recettes synchronisé (tous injoignables ou archivés)
+archive-config-dir-fail = impossible de déterminer le répertoire de config global (dirs::config_dir)
+archive-dry-archive = [dry-run] archiverait '{ $url }' dans { $path }
+archive-dry-unarchive = [dry-run] désarchiverait '{ $url }' dans { $path }
+archive-done = archivé '{ $url }' dans { $path }
+unarchive-done = désarchivé '{ $url }' dans { $path }
+
+# --- promote (src/promote.rs) ---
+promote-repo-not-configured = le dépôt '{ $url }' n'est pas une entrée [[recipe_repos]] configurée — ajoute-le à la config (le promote a besoin de son token pour le push)
+promote-checkout-missing = le dépôt '{ $url }' n'est pas synchronisé — lance `sm121 repos sync` (dry-run : pas d'I/O git)
+promote-checkout-sync-fail = le dépôt '{ $url }' n'est pas synchronisé — synchronise-le d'abord (sm121 repos sync)
+promote-detached-head = le checkout { $path } est en detached HEAD — passe d'abord sur une branche
+promote-same-repo = --from et --to doivent être des dépôts différents
+promote-recipe-not-in-source = la recette '{ $recipe }' est introuvable dans le checkout du dépôt source ({ $path }) — le checkout est-il à jour ? (sm121 repos sync)
+promote-recipe-in-target = la recette '{ $recipe }' existe déjà dans le dépôt cible — écrasement refusé (supprime-la d'abord là-bas)
+promote-cp-failed = cp a échoué : { $err }
+promote-cp-context = exécution de cp
+promote-target-dir-context = répertoire recipes/ de la cible
+promote-run-start = promotion de '{ $recipe }' de { $from } vers { $to } ({ $count } { $count ->
+        [one] étape
+       *[other] étapes
+    })
+promote-source-checkout = checkout source : { $path }
+promote-target-checkout = checkout cible : { $path }
+promote-step-line = [{ $side }] { $description }
+promote-done = promue '{ $recipe }' — la cible l'a désormais ; la prochaine synchro des deux dépôts consolide le déplacement
+promote-dry-head = [dry-run] promouvrait '{ $recipe }' de { $from } vers { $to } :
+promote-dry-suppressed = (supprimé : -d dry-run l'emporte sur --apply)
+promote-run-apply-hint = relance avec --apply pour exécuter
+
+# --- registry image source (src/docker/registry_pull.rs) ---
+registry-needs-registry = --image-source registry exige une section [registry] dans la config
+registry-ref-map-fail = impossible de mapper l'image '{ $image }' vers un ref du registre sous base '{ $base }' (le nom d'image doit être un simple <name>[:tag])
+registry-pull-context = pull du registre sur '{ $node }'
+registry-present = l'image '{ $image }' du registre est déjà présente sur '{ $node }' (label checksum identique)
+registry-pulled = récupérée '{ $ref }' → '{ $image }' sur '{ $node }' (label checksum vérifié)
+registry-dry-run = [dry-run] --image-source registry : tirerait { $ref } → tag '{ $image }' sur { $count } { $count ->
+        [one] nœud
+       *[other] nœuds
+    } (checksum { $checksum }…)
+registry-tag-not-found = { $node } : le tag '{ $ref }' n'est pas dans le registre — build + push d'abord (sm121 build --recipe { $recipe } --build-node <node>), ou reviens à --image-source repo
+registry-tag-failed = { $node } : docker tag a échoué après le pull
+registry-label-mismatch = { $node } : le label sm121.checksum de l'image récupérée est '{ $got }', attendu '{ $checksum }' — le tag du registre ne porte pas cette build
+registry-output-unparseable = { $node } : sortie du pull ininterprétable : { $out }
+registry-pull-failed = --image-source registry a échoué sur : { $failed }
+
+# --- drop_caches hook (src/docker/mod.rs) ---
+drop-caches-start = Vide le cache de pages sur tous les nœuds...
+drop-caches-failed = drop_caches sur { $node } a échoué : { $err }

+ 67 - 18
locales/it/main.ftl

@@ -9,11 +9,6 @@ container-log-header = 📜 Log dei container più recenti:
 cluster-stopping = 🛑 Arresto del cluster '{ $name }'...
 cluster-all-stopped = ✅ Tutti i container arrestati.
 cluster-section = === Cluster: { $name } ===
-container-count = Container totali: { $count }
-no-sm121-containers = (nessun container sm121)
-docker-unavailable = ✗ Docker non disponibile
-ssh-error = { $node } ({ $ip }): ✗ Errore SSH: { $error }
-task-join-error = ✗ Errore di join dell'attività: { $error }
 doctor-header = 🩺 Doctor: { $name } ({ $engine }) [backend: { $backend }]
 
 doctor-node-section = --- { $name } ---
@@ -24,10 +19,6 @@ doctor-repaired = ✅ Immagine riparata: { $tag }
 doctor-dry-build = (dry-run) Costruirebbe { $tag } su { $node }
 logs-follow-head = 📜 Seguendo i log di '{ $name }' (nodo head)...
 logs-section = --- { $name } ---
-stop-failed = Arresto fallito su { $name }: { $error }
-remove-failed = Rimozione fallita su { $name }: { $error }
-failed-to = { $action } fallita su { $name }: { $error }
-health-section = === Cluster: { $name } ===
 health-cluster-header = === { $name } === { $nodes } { $nodes ->
         [one] nodo
        *[other] nodi
@@ -46,9 +37,6 @@ health-soft-off = ⚙ { $list } spento (non bloccante)
 health-net-status =  [rete: { $details }]
 health-no-containers = (nessun container)
 health-no-containers-inline = , nessun container
-health-total = Container totali: { $count }
-health-docker-unavailable = ✗ Docker non disponibile
-docker-error-line = { $err }
 exec-header = ⚡ exec: { $cmd } ({ $nodes } { $nodes ->
         [one] nodo
        *[other] nodi
@@ -82,7 +70,6 @@ ha-invalid-entity = { $name }: entity non valida '{ $entity }': { $error }
 ha-call-failed = HA { $service } { $entity }: { $error }
 
 ha-skip-no-entity = ⚠ { $name }: nessuna `entity` configurata, salto HA
-ha-extra-invalid = { $label } ({ $extra }): entity non valida '{ $extra }'
 ha-extra-failed = HA { $service } { $extra } ({ $label }): { $error }
 
 power-switch-would-off = sarebbe: HA turn_off { $entity }
@@ -91,7 +78,6 @@ power-switch-would-on = sarebbe: HA turn_on { $entity }
 power-switch-would-on-extra = sarebbe: HA turn_on { $extra } (verso extras)
 power-switch-shutdown-failed = ✗ { $name } spegnimento: { $error }
 power-switch-waiting = ⏳ attesa { $secs}s per spegnimento
-ha-entity-error = { $name }: entità '{ $entity }': { $error }
 power-switch-no-ha = nessuna config [home_assistant]; spegnimento OS eseguito
 power-off-entity-would = sarebbe: HA turn_off { $entity }
 pre-serve-skip = ⏭ Saltando hook pre-serve (--skip-before-serve)
@@ -108,7 +94,6 @@ cluster-switch-powered-on = ✅ Switch del cluster acceso
 cluster-waiting-for-nodes = ⏳ In attesa che i nodi siano disponibili...
 cluster-nodes-available = ✅ Tutti i nodi ora sono disponibili
 cluster-nodes-not-available-timeout = ❌ Timeout nell'attesa della disponibilità dei nodi
-ha-config-required-for-power-on = ❌ Config HA richiesta per l'accensione
 logs-header = 📜 Log del cluster '{ $name }' ({ $count } nodi)
 engine-started = ✅ { $engine } avviato su { $name } (rango { $rank })
 engine-start-failed = Avvio di { $engine } su { $name } (rango { $rank }) fallito: { $error }
@@ -125,7 +110,6 @@ exec-unknown-alias-list = Alias sconosciuto @{ $name }; alias noti: { $aliases }
 mods-uploading = 📤 Caricamento mod su { $node }...
 mods-uploaded = ✅ Mod caricati su { $node }
 mods-entrypoint-required = La ricetta '{ $name }' ha mod ma nessun entrypoint; l'entrypoint è obbligatorio quando sono presenti mod
-power-off-container-stop-failed = ⚠️ Impossibile arrestare i container sul nodo { $name }: { $error}
 
 after-stop-failed = after_stop: nodo { $name } fallito: { $error }
 after-stop-run = { $node } ({ $tag }) :: { $cmd }
@@ -202,7 +186,6 @@ tmux-composed-model-unsupported = Recipe '{ $name }' usa [composed_model], che 
 tmux-health-section = Tmux:
 tmux-health-entry-running = { $recipe } — { $node }: in esecuzione (API: { $api })
 tmux-health-entry-degraded = { $recipe } — { $node }: degradata (la sessione esiste, processo morto)
-tmux-health-entry-stopped = { $recipe } — { $node }: fermato
 tmux-health-all-stopped = { $count ->
     [one] { $count } ricetta tmux configurata, tutte fermate
    *[other] { $count } ricette tmux configurate, tutte fermate
@@ -213,7 +196,6 @@ venv-preparing = 🔧 Preparazione venv: { $venv }
 venv-exists = ✓ venv esiste già su { $name }
 venv-creating = venv non trovato su { $name } — creazione...
 venv-created = ✓ venv creato con successo su { $name }
-venv-prepare-failed = preparazione venv: nodo { $name } fallito: { $error }
 venv-not-created = venv non creato correttamente su { $name } — i comandi di configurazione sono stati eseguiti ma manca lo script activate
 
 # health render module (plain/md/html)
@@ -275,3 +257,70 @@ report-col-tps1 = Tok/s c1
 report-col-tps2 = Tok/s c2
 report-serving = Report interattivo delle ricette: { $url }  (aggiornamenti live; Ctrl-C per fermare)
 report-title = Ricette testate ({ $count })
+
+# --- recipe repos (src/recipe_repos.rs) ---
+git-failed = git { $cmd } è fallito: { $err }
+recipe-repo-archived-skip = repository di ricette archiviato — salto: { $url }
+recipe-repo-synced = repository di ricette sincronizzato: { $url } → { $dir }
+recipe-repo-unreachable = repository di ricette '{ $url }' non raggiungibile — salto ({ $err }); proseguo con i repository raggiungibili
+repos-registry-corrupt = il registro dei repository '{ $path }' è corrotto ({ $err }) — lo sposto in '{ $bak }' e riparto
+repos-registry-backup-failed = impossibile salvare una copia del registro corrotto { $path }
+repos-none-configured = nessun [[recipe_repos]] configurato
+repos-col-repo = repository
+repos-col-state = stato
+repos-col-last = ultima sincronizzazione
+repos-col-count = ricette
+repos-state-archived = archiviato
+repos-state-synced = sincronizzato
+repos-state-not-cloned = non clonato
+repos-sync-dry-reclone = [dry-run] riclonerebbe il repository di ricette { $url } → { $dir }
+repos-sync-dry-sync = [dry-run] clonerebbe/aggiornerebbe il repository di ricette { $url } → { $dir }
+repos-none-synced = nessun repository di ricette sincronizzato (tutti irraggiungibili o archiviati)
+archive-config-dir-fail = impossibile determinare la directory di configurazione globale (dirs::config_dir)
+archive-dry-archive = [dry-run] archiverebbe '{ $url }' in { $path }
+archive-dry-unarchive = [dry-run] de-archiverebbe '{ $url }' in { $path }
+archive-done = archiviato '{ $url }' in { $path }
+unarchive-done = de-archiviato '{ $url }' in { $path }
+
+# --- promote (src/promote.rs) ---
+promote-repo-not-configured = il repository '{ $url }' non è una voce [[recipe_repos]] configurata — aggiungilo alla configurazione (il promote richiede il suo token per il push)
+promote-checkout-missing = il repository '{ $url }' non è sincronizzato — esegui `sm121 repos sync` (dry-run: nessun I/O git)
+promote-checkout-sync-fail = il repository '{ $url }' non è sincronizzato — sincronizzalo prima (sm121 repos sync)
+promote-detached-head = il checkout { $path } è in detached HEAD — passa prima a un branch
+promote-same-repo = --from e --to devono essere repository diversi
+promote-recipe-not-in-source = la ricetta '{ $recipe }' non è stata trovata nel checkout del repository di origine ({ $path }) — il checkout è aggiornato? (sm121 repos sync)
+promote-recipe-in-target = la ricetta '{ $recipe }' esiste già nel repository di destinazione — sovrascrittura rifiutata (rimuovila prima lì)
+promote-cp-failed = cp è fallito: { $err }
+promote-cp-context = esecuzione di cp
+promote-target-dir-context = directory recipes/ della destinazione
+promote-run-start = promuovo '{ $recipe }' da { $from } a { $to } ({ $count } { $count ->
+        [one] passo
+       *[other] passi
+    })
+promote-source-checkout = checkout di origine: { $path }
+promote-target-checkout = checkout di destinazione: { $path }
+promote-step-line = [{ $side }] { $description }
+promote-done = promossa '{ $recipe }' — la destinazione ce l'ha; la prossima sincronizzazione di entrambi i repository consolida lo spostamento
+promote-dry-head = [dry-run] promuoverebbe '{ $recipe }' da { $from } a { $to }:
+promote-dry-suppressed = (soppresso: -d dry-run ha precedenza su --apply)
+promote-run-apply-hint = esegui di nuovo con --apply per applicare
+
+# --- registry image source (src/docker/registry_pull.rs) ---
+registry-needs-registry = --image-source registry richiede una sezione [registry] nella configurazione
+registry-ref-map-fail = impossibile mappare l'immagine '{ $image }' a un ref del registro sotto base '{ $base }' (il nome immagine deve essere un semplice <name>[:tag])
+registry-pull-context = pull dal registro su '{ $node }'
+registry-present = l'immagine '{ $image }' dal registro è già presente su '{ $node }' (etichetta checksum corrispondente)
+registry-pulled = scaricata '{ $ref }' → '{ $image }' su '{ $node }' (etichetta checksum verificata)
+registry-dry-run = [dry-run] --image-source registry: scaricherebbe { $ref } → tag '{ $image }' su { $count } { $count ->
+        [one] nodo
+       *[other] nodi
+    } (checksum { $checksum }…)
+registry-tag-not-found = { $node }: il tag '{ $ref }' non è nel registro — compila e fai push prima (sm121 build --recipe { $recipe } --build-node <node>), o torna a --image-source repo
+registry-tag-failed = { $node }: docker tag fallito dopo il download
+registry-label-mismatch = { $node }: l'etichetta sm121.checksum dell'immagine scaricata è '{ $got }', atteso '{ $checksum }' — il tag del registro non contiene questa build
+registry-output-unparseable = { $node }: output del download non interpretabile: { $out }
+registry-pull-failed = --image-source registry è fallito su: { $failed }
+
+# --- drop_caches hook (src/docker/mod.rs) ---
+drop-caches-start = Svuoto la cache di pagina su tutti i nodi...
+drop-caches-failed = drop_caches su { $node } è fallito: { $err }

+ 69 - 18
locales/ru/main.ftl

@@ -10,11 +10,6 @@ container-log-header = 📜 Последние логи контейнеров:
 cluster-stopping = 🛑 Останавливаем кластер '{ $name }'...
 cluster-all-stopped = ✅ Все контейнеры остановлены.
 cluster-section = === Кластер: { $name } ===
-container-count = Всего контейнеров: { $count }
-no-sm121-containers = (нет контейнеров sm121)
-docker-unavailable = ✗ Docker недоступен
-ssh-error = { $node } ({ $ip }): ✗ SSH ошибка: { $error }
-task-join-error = ✗ Ошибка присоединения задачи: { $error }
 doctor-header = 🩺 Doctor: { $name } ({ $engine }) [бэкенд: { $backend }]
 
 doctor-node-section = --- { $name } ---
@@ -25,10 +20,6 @@ doctor-repaired = ✅ Образ восстановлен: { $tag }
 doctor-dry-build = (dry-run) Был бы собран { $tag } на { $node }
 logs-follow-head = 📜 Наблюдаю за логами '{ $name }' (главный узел)...
 logs-section = --- { $name } ---
-stop-failed = Не удалось остановить { $name }: { $error }
-remove-failed = Не удалось удалить { $name }: { $error }
-failed-to = Не удалось { $action } на { $name }: { $error }
-health-section = === Кластер: { $name } ===
 health-cluster-header = === { $name } === { $nodes } { $nodes ->
         [one] узел
        *[other] узлов
@@ -47,9 +38,6 @@ health-soft-off = ⚙ { $list } выкл. (не блокирует)
 health-net-status =  [сеть: { $details }]
 health-no-containers = (нет контейнеров)
 health-no-containers-inline = , нет контейнеров
-health-total = Всего контейнеров: { $count }
-health-docker-unavailable = ✗ Docker недоступен
-docker-error-line = { $err }
 exec-header = ⚡ exec: { $cmd } ({ $nodes } { $nodes ->
         [one] узел
        *[other] узлов
@@ -83,7 +71,6 @@ ha-invalid-entity = { $name }: неверный entity '{ $entity }': { $error }
 ha-call-failed = HA { $service } { $entity }: { $error }
 
 ha-skip-no-entity = ⚠ { $name }: нет `entity`, пропускаем HA
-ha-extra-invalid = { $label } ({ $extra }): неверный entity '{ $extra }'
 ha-extra-failed = HA { $service } { $extra } ({ $label }): { $error }
 
 power-switch-would-off = будет: HA turn_off { $entity }
@@ -92,7 +79,6 @@ power-switch-would-on = будет: HA turn_on { $entity }
 power-switch-would-on-extra = будет: HA turn_on { $extra } (в extras)
 power-switch-shutdown-failed = ✗ { $name } выключение: { $error }
 power-switch-waiting = ⏳ ждем { $secs }с для остановки
-ha-entity-error = { $name }: сущность '{ $entity }': { $error }
 power-switch-no-ha = нет [home_assistant] конфига; OS shutdown выполнен
 power-off-entity-would = будет: HA turn_off { $entity }
 pre-serve-skip = ⏭ Пропускаем pre-serve хуки (--skip-before-serve)
@@ -109,7 +95,6 @@ cluster-switch-powered-on = ✅ Переключатель кластера вк
 cluster-waiting-for-nodes = ⏳ Ожидание доступности узлов...
 cluster-nodes-available = ✅ Все узлы теперь доступны
 cluster-nodes-not-available-timeout = ❌ Превышено время ожидания доступности узлов
-ha-config-required-for-power-on = ❌ Для включения питания требуется HA конфиг
 logs-header = 📜 Логи кластера '{ $name }' ({ $count } узлов)
 engine-started = ✅ { $engine } запущен на { $name } (rank { $rank })
 engine-start-failed = Не удалось запустить { $engine } на { $name } (rank { $rank }): { $error }
@@ -126,7 +111,6 @@ exec-unknown-alias-list = Неизвестный псевдоним @{ $name };
 mods-uploading = 📤 Загрузка модов на { $node }...
 mods-uploaded = ✅ Моды загружены на { $node }
 mods-entrypoint-required = Рецепт '{ $name }' содержит моды, но не имеет entrypoint; entrypoint обязателен при наличии модов
-power-off-container-stop-failed = ⚠️ Не удалось остановить контейнеры на узле { $name }: { $error}
 
 after-stop-failed = after_stop: узел { $name } ошибка: { $error }
 after-stop-run = { $node } ({ $tag }) :: { $cmd }
@@ -209,7 +193,6 @@ tmux-composed-model-unsupported = Recipe '{ $name }' использует [compo
 tmux-health-section = Tmux:
 tmux-health-entry-running = { $recipe } — { $node }: запущен (API: { $api })
 tmux-health-entry-degraded = { $recipe } — { $node }: деградирован (сессия существует, процесс мёртв)
-tmux-health-entry-stopped = { $recipe } — { $node }: остановлен
 tmux-health-all-stopped = { $count ->
     [one] { $count } tmux-рецепт настроен, все остановлены
     [few] { $count } tmux-рецепта настроены, все остановлены
@@ -221,7 +204,6 @@ venv-preparing = 🔧 Подготовка venv: { $venv }
 venv-exists = ✓ venv уже существует на { $name }
 venv-creating = venv не найден на { $name } — создаю...
 venv-created = ✓ venv успешно создан на { $name }
-venv-prepare-failed = подготовка venv: узел { $name } завершился с ошибкой: { $error }
 venv-not-created = venv не был создан успешно на { $name } — команды настройки выполнились, но скрипт activate отсутствует
 
 # health render module (plain/md/html)
@@ -285,3 +267,72 @@ power-on-off-wait-timeout = { $name }: вилка не ушла в off за 10 
 report-col-launches = Запуски
 report-col-up = Аптайм
 stop-not-verified = Контейнеры не подтверждены как остановленные на: { $nodes }
+
+# --- recipe repos (src/recipe_repos.rs) ---
+git-failed = git { $cmd } завершился ошибкой: { $err }
+recipe-repo-archived-skip = рецепт-репозиторий в архиве — пропускаю: { $url }
+recipe-repo-synced = рецепт-репозиторий синхронизирован: { $url } → { $dir }
+recipe-repo-unreachable = рецепт-репозиторий '{ $url }' недоступен — пропускаю ({ $err }); продолжаю с доступными репозиториями
+repos-registry-corrupt = реестр репозиториев '{ $path }' повреждён ({ $err }) — переношу его в '{ $bak }' и начинаю заново
+repos-registry-backup-failed = не удалось сохранить копию повреждённого реестра { $path }
+repos-none-configured = ни один [[recipe_repos]] не настроен
+repos-col-repo = репозиторий
+repos-col-state = состояние
+repos-col-last = последняя синхронизация
+repos-col-count = рецептов
+repos-state-archived = в архиве
+repos-state-synced = синхронизирован
+repos-state-not-cloned = не клонирован
+repos-sync-dry-reclone = [dry-run] переклонировал бы рецепт-репозиторий { $url } → { $dir }
+repos-sync-dry-sync = [dry-run] склонировал бы/обновил рецепт-репозиторий { $url } → { $dir }
+repos-none-synced = ни один рецепт-репозиторий не синхронизирован (все недоступны или в архиве)
+archive-config-dir-fail = не удалось определить каталог глобального конфига (dirs::config_dir)
+archive-dry-archive = [dry-run] заархивировал бы '{ $url }' в { $path }
+archive-dry-unarchive = [dry-run] разархивировал бы '{ $url }' в { $path }
+archive-done = заархивировано '{ $url }' в { $path }
+unarchive-done = разархивировано '{ $url }' в { $path }
+
+# --- promote (src/promote.rs) ---
+promote-repo-not-configured = репозиторий '{ $url }' не входит в настроенные [[recipe_repos]] — добавьте его в конфиг (для promote нужен его токен для push)
+promote-checkout-missing = репозиторий '{ $url }' не синхронизирован — выполните `sm121 repos sync` (dry-run: без git I/O)
+promote-checkout-sync-fail = репозиторий '{ $url }' не синхронизирован — сначала синхронизируйте (sm121 repos sync)
+promote-detached-head = checkout { $path } в состоянии detached HEAD — сначала переключитесь на ветку
+promote-same-repo = --from и --to должны быть разными репозиториями
+promote-recipe-not-in-source = рецепт '{ $recipe }' не найден в checkout исходного репозитория ({ $path }) — checkout актуален? (sm121 repos sync)
+promote-recipe-in-target = рецепт '{ $recipe }' уже есть в целевом репозитории — перезапись запрещена (сначала удалите его там)
+promote-cp-failed = cp завершился ошибкой: { $err }
+promote-cp-context = запуск cp
+promote-target-dir-context = каталог recipes/ в целевом репозитории
+promote-run-start = переношу '{ $recipe }' из { $from } в { $to } ({ $count } { $count ->
+        [one] шаг
+        [few] шага
+       *[other] шагов
+    })
+promote-source-checkout = checkout источника: { $path }
+promote-target-checkout = checkout цели: { $path }
+promote-step-line = [{ $side }] { $description }
+promote-done = перенесено '{ $recipe }' — теперь он в целевом репозитории; следующая синхронизация обоих репозиториев закрепит перенос
+promote-dry-head = [dry-run] перенос '{ $recipe }' из { $from } в { $to }:
+promote-dry-suppressed = (подавлено: -d dry-run имеет приоритет над --apply)
+promote-run-apply-hint = перезапустите с --apply для выполнения
+
+# --- registry image source (src/docker/registry_pull.rs) ---
+registry-needs-registry = --image-source registry требует секцию [registry] в конфиге
+registry-ref-map-fail = не удалось сопоставить образ '{ $image }' с registry ref под base '{ $base }' (имя образа должно быть простым <name>[:tag])
+registry-pull-context = registry pull на '{ $node }'
+registry-present = образ '{ $image }' из реестра уже есть на '{ $node }' (метка checksum совпадает)
+registry-pulled = скачано '{ $ref }' → '{ $image }' на '{ $node }' (метка checksum проверена)
+registry-dry-run = [dry-run] --image-source registry: скачаю { $ref } → тег '{ $image }' на { $count } { $count ->
+        [one] узле
+        [few] узлах
+       *[other] узлах
+    } (checksum { $checksum }…)
+registry-tag-not-found = { $node }: тега '{ $ref }' нет в реестре — сначала соберите и запушьте (sm121 build --recipe { $recipe } --build-node <node>) или вернитесь к --image-source repo
+registry-tag-failed = { $node }: docker tag после скачивания не удался
+registry-label-mismatch = { $node }: метка sm121.checksum скачанного образа — '{ $got }', ожидалось '{ $checksum }' — тег в реестре не содержит эту сборку
+registry-output-unparseable = { $node }: не удалось разобрать вывод скачивания: { $out }
+registry-pull-failed = --image-source registry завершился ошибкой на: { $failed }
+
+# --- drop_caches hook (src/docker/mod.rs) ---
+drop-caches-start = Сбрасываю page cache на всех узлах...
+drop-caches-failed = drop_caches на { $node } не удался: { $err }

+ 5 - 2
src/docker/mod.rs

@@ -293,11 +293,14 @@ pub async fn serve(
     // Drop page cache on all nodes right before starting containers.
     // Must run after image sync (docker load fills page cache).
     if config.drop_caches && !opts.dry_run {
-        info!("Dropping page cache on all nodes...");
+        info!("{}", fluent_i18n::t!("drop-caches-start"));
         for node in &cluster.nodes {
             let cmd = "echo 3 | sudo tee /proc/sys/vm/drop_caches".to_string();
             if let Err(e) = run_ssh(config, node, cmd, false).await {
-                warn!("drop_caches on {} failed: {}", node.name, e);
+                warn!(
+                    "{}",
+                    fluent_i18n::t!("drop-caches-failed", { "node" => &node.name, "err" => e.to_string() })
+                );
             }
         }
     }

+ 28 - 22
src/docker/registry_pull.rs

@@ -25,7 +25,7 @@ pub fn registry_pull_refs(config: &Config, recipe: &Recipe) -> Result<(String, S
     let reg = config
         .registry
         .as_ref()
-        .context("--image-source registry requires a [registry] section in the config")?;
+        .context(fluent_i18n::t!("registry-needs-registry"))?;
     let image_dir = recipe
         .docker_image_dir
         .clone()
@@ -43,9 +43,9 @@ pub fn registry_pull_refs(config: &Config, recipe: &Recipe) -> Result<(String, S
     )?;
     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
+        .context(fluent_i18n::t!(
+            "registry-ref-map-fail",
+            { "image" => recipe.docker_image.clone(), "base" => reg.base.clone() }
         ))?;
     Ok((version_ref, latest_ref, checksum))
 }
@@ -140,19 +140,19 @@ pub async fn ensure_node(
         false,
     )
     .await
-    .with_context(|| format!("registry pull on '{}'", node.name))?;
+    .with_context(|| fluent_i18n::t!("registry-pull-context", { "node" => node.name.clone() }))?;
     let outcome = parse_node_output(&out);
     match &outcome {
         NodeOutcome::Present => {
             info!(
-                "registry image '{}' already present on '{}' (checksum label matches)",
-                image, node.name
+                "{}",
+                fluent_i18n::t!("registry-present", { "image" => image.to_string(), "node" => node.name.clone() })
             );
         }
         NodeOutcome::Pulled => {
             info!(
-                "pulled '{}' → '{}' on '{}' (checksum label verified)",
-                version_ref, image, node.name
+                "{}",
+                fluent_i18n::t!("registry-pulled", { "ref" => version_ref.to_string(), "image" => image.to_string(), "node" => node.name.clone() })
             );
         }
         _ => {}
@@ -172,11 +172,8 @@ pub async fn ensure_registry_image(
     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())]
+            "{}",
+            fluent_i18n::t!("registry-dry-run", { "ref" => version_ref.clone(), "image" => recipe.docker_image.clone(), "count" => cluster.nodes.len(), "checksum" => checksum[..8.min(checksum.len())].to_string() })
         );
         return Ok(());
     }
@@ -202,22 +199,31 @@ pub async fn ensure_registry_image(
         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::PullFailed) => failed.push(fluent_i18n::t!(
+                "registry-tag-not-found",
+                { "node" => name, "ref" => version_ref.clone(), "recipe" => recipe.name.clone() }
             )),
-            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::TagFailed) => {
+                failed.push(fluent_i18n::t!("registry-tag-failed", { "node" => &name }));
+            }
+            Ok(NodeOutcome::LabelMismatch(got)) => failed.push(fluent_i18n::t!(
+                "registry-label-mismatch",
+                { "node" => name, "got" => got, "checksum" => checksum.clone() }
+            )),
+            Ok(NodeOutcome::Failed(out)) => failed.push(fluent_i18n::t!(
+                "registry-output-unparseable",
+                { "node" => name, "out" => out }
             )),
-            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("; "))
+        anyhow::bail!(fluent_i18n::t!(
+            "registry-pull-failed",
+            { "failed" => failed.join("; ") }
+        ))
     }
 }
 

+ 9 - 0
src/locale.rs

@@ -39,6 +39,15 @@ where
     })
 }
 
+/// Re-apply the process-global locale to the CURRENT thread. Call at the
+/// top of a `tokio::task::spawn_blocking` closure (those threads are
+/// fresh and do not inherit the thread-local locale either).
+pub fn apply_global_locale() {
+    if let Some(loc) = GLOBAL_LOCALE.get().and_then(|opt| opt.clone()) {
+        let _ = fluent_i18n::set_locale(Some(loc.as_str()));
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;

+ 64 - 49
src/promote.rs

@@ -55,11 +55,9 @@ fn find_repo<'a>(config: &'a Config, url: &str) -> Result<&'a RecipeRepo> {
         .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)"
-            )
-        })
+        .with_context(
+            || fluent_i18n::t!("promote-repo-not-configured", { "url" => url.to_string() }),
+        )
 }
 
 /// The checkout dir of a repo, synced on demand (a promote must not plan
@@ -69,17 +67,14 @@ 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
-            );
+            anyhow::bail!(fluent_i18n::t!(
+                "promote-checkout-missing",
+                { "url" => &repo.url }
+            ));
         }
-        sync_repo(repo).with_context(|| {
-            format!(
-                "repo '{}' is not synced — sync it first (sm121 repos sync)",
-                repo.url
-            )
-        })?;
+        sync_repo(repo).with_context(
+            || fluent_i18n::t!("promote-checkout-sync-fail", { "url" => &repo.url }),
+        )?;
     }
     Ok(dir)
 }
@@ -93,10 +88,10 @@ fn branch_of(checkout: &Path) -> Result<String> {
         "HEAD",
     ])?;
     if b.is_empty() || b == "HEAD" {
-        anyhow::bail!(
-            "checkout {} is in detached HEAD — check out a branch first",
-            checkout.display()
-        );
+        anyhow::bail!(fluent_i18n::t!(
+            "promote-detached-head",
+            { "path" => checkout.display().to_string() }
+        ));
     }
     Ok(b)
 }
@@ -117,7 +112,7 @@ pub fn plan(
     sync_missing: bool,
 ) -> Result<PromotePlan> {
     if from_url == to_url {
-        anyhow::bail!("--from and --to must be different repos");
+        anyhow::bail!(fluent_i18n::t!("promote-same-repo"));
     }
     let from = find_repo(config, from_url)?;
     let to = find_repo(config, to_url)?;
@@ -127,17 +122,16 @@ pub fn plan(
     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()
-        );
+        anyhow::bail!(fluent_i18n::t!(
+            "promote-recipe-not-in-source",
+            { "recipe" => recipe.to_string(), "path" => from_dir.display().to_string() }
+        ));
     }
     if to_recipe.exists() {
-        anyhow::bail!(
-            "recipe '{}' already exists in the target repo — refusing to overwrite (remove it there first)",
-            recipe
-        );
+        anyhow::bail!(fluent_i18n::t!(
+            "promote-recipe-in-target",
+            { "recipe" => recipe.to_string() }
+        ));
     }
 
     let from_branch = branch_of(&from_dir)?;
@@ -238,15 +232,21 @@ 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")?)?;
+                std::fs::create_dir_all(
+                    to.parent()
+                        .context(fluent_i18n::t!("promote-target-dir-context"))?,
+                )?;
                 let out = std::process::Command::new("cp")
                     .arg("-a")
                     .arg(from)
                     .arg(to)
                     .output()
-                    .context("running cp")?;
+                    .context(fluent_i18n::t!("promote-cp-context"))?;
                 if !out.status.success() {
-                    anyhow::bail!("cp failed: {}", String::from_utf8_lossy(&out.stderr).trim());
+                    anyhow::bail!(fluent_i18n::t!(
+                        "promote-cp-failed",
+                        { "err" => String::from_utf8_lossy(&out.stderr).trim().to_string() }
+                    ));
                 }
             }
             Run::Git { cwd, args } => {
@@ -259,7 +259,10 @@ pub fn execute(plan: &PromotePlan) -> Result<()> {
                 git(&["-C", &cwd.display().to_string(), "push", url, branch])?;
             }
         }
-        println!("  [{}] {}", step.side, step.description);
+        println!(
+            "{}",
+            fluent_i18n::t!("promote-step-line", { "side" => step.side, "description" => &step.description })
+        );
     }
     Ok(())
 }
@@ -277,34 +280,46 @@ pub fn run(
     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()
+            "{}",
+            fluent_i18n::t!("promote-run-start", { "recipe" => &plan.recipe, "from" => &plan.from_url, "to" => &plan.to_url, "count" => plan.steps.len() })
+        );
+        println!(
+            "{}",
+            fluent_i18n::t!("promote-source-checkout", { "path" => plan.from_checkout.display().to_string() })
+        );
+        println!(
+            "{}",
+            fluent_i18n::t!("promote-target-checkout", { "path" => plan.to_checkout.display().to_string() })
         );
-        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
+            "{}",
+            fluent_i18n::t!("promote-done", { "recipe" => &plan.recipe })
         );
         return Ok(());
     }
     println!(
-        "[dry-run] promote '{}' from {} to {}:",
-        plan.recipe, plan.from_url, plan.to_url
+        "{}",
+        fluent_i18n::t!("promote-dry-head", { "recipe" => &plan.recipe, "from" => &plan.from_url, "to" => &plan.to_url })
+    );
+    println!(
+        "{}",
+        fluent_i18n::t!("promote-source-checkout", { "path" => plan.from_checkout.display().to_string() })
+    );
+    println!(
+        "{}",
+        fluent_i18n::t!("promote-target-checkout", { "path" => plan.to_checkout.display().to_string() })
     );
-    println!("  source checkout: {}", plan.from_checkout.display());
-    println!("  target checkout: {}", plan.to_checkout.display());
     for step in &plan.steps {
-        println!("  [{}] {}", step.side, step.description);
+        println!(
+            "{}",
+            fluent_i18n::t!("promote-step-line", { "side" => step.side, "description" => &step.description })
+        );
     }
     if apply {
-        println!("(suppressed: -d dry-run wins over --apply)");
+        println!("{}", fluent_i18n::t!("promote-dry-suppressed"));
     }
-    println!("re-run with --apply to execute");
+    println!("{}", fluent_i18n::t!("promote-run-apply-hint"));
     Ok(())
 }
 

+ 68 - 36
src/recipe_repos.rs

@@ -56,11 +56,10 @@ pub(crate) fn git(args: &[&str]) -> Result<String> {
         })?;
     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
-        );
+        anyhow::bail!(fluent_i18n::t!(
+            "git-failed",
+            { "cmd" => args.first().copied().unwrap_or("").to_string(), "err" => err }
+        ));
     }
     Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
 }
@@ -148,13 +147,18 @@ pub fn load_registry(path: &Path) -> ReposRegistry {
             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()
+                    "{}",
+                    fluent_i18n::t!(
+                        "repos-registry-corrupt",
+                        { "path" => path.display().to_string(), "err" => e.to_string(), "bak" => path.with_extension("json.bak").display().to_string() }
+                    )
                 );
                 let bak = path.with_extension("json.bak");
                 if std::fs::copy(path, &bak).is_err() {
-                    warn!("could not back up corrupt registry {}", path.display());
+                    warn!(
+                        "{}",
+                        fluent_i18n::t!("repos-registry-backup-failed", { "path" => path.display().to_string() })
+                    );
                 }
                 ReposRegistry::default()
             }
@@ -207,19 +211,25 @@ pub fn sync_repos(
     let mut roots = Vec::new();
     for repo in repos {
         if registry.is_archived(&repo.url) {
-            info!("recipe repo archived — skipping: {}", repo.url);
+            info!(
+                "{}",
+                fluent_i18n::t!("recipe-repo-archived-skip", { "url" => &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());
+                info!(
+                    "{}",
+                    fluent_i18n::t!("recipe-repo-synced", { "url" => &repo.url, "dir" => dir.display().to_string() })
+                );
                 roots.push(dir);
             }
             Err(e) => {
                 warn!(
-                    "recipe repo '{}' unreachable — skipping ({}); continuing with the reachable repos",
-                    repo.url, e
+                    "{}",
+                    fluent_i18n::t!("recipe-repo-unreachable", { "url" => &repo.url, "err" => e.to_string() })
                 );
             }
         }
@@ -236,6 +246,7 @@ pub async fn sync_recipe_repos(config: &crate::config::Config) -> Vec<PathBuf> {
     let base = cache_base();
     let registry = reg_path.as_deref().map(load_registry).unwrap_or_default();
     tokio::task::spawn_blocking(move || {
+        crate::locale::apply_global_locale();
         let _ = std::fs::create_dir_all(&base);
         sync_repos(&repos, &base, &registry, false)
     })
@@ -274,21 +285,27 @@ pub async fn run(config: &crate::config::Config, cmd: &ReposCommands, dry_run: b
     match cmd {
         ReposCommands::List => {
             if config.recipe_repos.is_empty() {
-                println!("no [[recipe_repos]] configured");
+                println!("{}", fluent_i18n::t!("repos-none-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");
+            println!(
+                "{:<52} {:<22} {:<18} {n}",
+                fluent_i18n::t!("repos-col-repo"),
+                fluent_i18n::t!("repos-col-state"),
+                fluent_i18n::t!("repos-col-last"),
+                n = fluent_i18n::t!("repos-col-count")
+            );
             for repo in &config.recipe_repos {
                 let dir = repo_cache_dir(&base, &repo.url);
                 let state = if reg.is_archived(&repo.url) {
-                    "archived"
+                    fluent_i18n::t!("repos-state-archived")
                 } else if dir.join(".git").is_dir() {
-                    "synced"
+                    fluent_i18n::t!("repos-state-synced")
                 } else {
-                    "not cloned"
+                    fluent_i18n::t!("repos-state-not-cloned")
                 };
                 let last = std::fs::read_to_string(dir.join(LAST_SYNC_FILE))
                     .ok()
@@ -305,12 +322,17 @@ pub async fn run(config: &crate::config::Config, cmd: &ReposCommands, dry_run: b
             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()
-                    );
+                    if force {
+                        println!(
+                            "{}",
+                            fluent_i18n::t!("repos-sync-dry-reclone", { "url" => &repo.url, "dir" => dir.display().to_string() })
+                        );
+                    } else {
+                        println!(
+                            "{}",
+                            fluent_i18n::t!("repos-sync-dry-sync", { "url" => &repo.url, "dir" => dir.display().to_string() })
+                        );
+                    }
                 }
                 return Ok(());
             }
@@ -322,7 +344,7 @@ pub async fn run(config: &crate::config::Config, cmd: &ReposCommands, dry_run: b
                 .await
                 .unwrap_or_default();
             if roots.is_empty() {
-                warn!("no recipe repos synced (all unreachable or archived)");
+                warn!("{}", fluent_i18n::t!("repos-none-synced"));
             }
         }
     }
@@ -332,24 +354,34 @@ pub async fn run(config: &crate::config::Config, cmd: &ReposCommands, dry_run: b
 /// `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)")
-    })?;
+    let path = registry_path()
+        .ok_or_else(|| anyhow::anyhow!(fluent_i18n::t!("archive-config-dir-fail")))?;
     if dry_run {
-        println!(
-            "[dry-run] would {} '{}' in {}",
-            if unarchive { "unarchive" } else { "archive" },
-            url,
-            path.display()
-        );
+        if unarchive {
+            println!(
+                "{}",
+                fluent_i18n::t!("archive-dry-unarchive", { "url" => url.to_string(), "path" => path.display().to_string() })
+            );
+        } else {
+            println!(
+                "{}",
+                fluent_i18n::t!("archive-dry-archive", { "url" => url.to_string(), "path" => path.display().to_string() })
+            );
+        }
         return Ok(());
     }
     if unarchive {
         unarchive_at(&path, url)?;
-        println!("unarchived '{url}' in {}", path.display());
+        println!(
+            "{}",
+            fluent_i18n::t!("unarchive-done", { "url" => url.to_string(), "path" => path.display().to_string() })
+        );
     } else {
         archive_at(&path, url)?;
-        println!("archived '{url}' in {}", path.display());
+        println!(
+            "{}",
+            fluent_i18n::t!("archive-done", { "url" => url.to_string(), "path" => path.display().to_string() })
+        );
     }
     Ok(())
 }