hub-record-authority.yaml assigns managed_repos to repo-manager as file-derived, but repo-manager exposed no command for it. The only working path lived in the State Hub repo and defaulted to 127.0.0.1:8000, which is how seven weeks of onboarding landed in a local cache instead of central. Order follows ADR-010 decision 5: make the source file correct and reachable first, then project it. Refuses to onboard when the classification file is missing or invalid, has uncommitted changes, has no upstream, or has unpushed commits — a hub record whose backing file is only local cannot be re-derived by anyone else. --api-base has no default on purpose. A silent localhost default is the original defect, not a convenience. A failed classification PATCH degrades to a warning rather than failing the run: the authoritative record existing is what stops a repository from being recoverable only through a discardable cache, and classification is a projection of a committed file that can be re-derived later. Refs CUST-WP-0067-T04 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 2583210@bnt-lap001 Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
1055 lines
45 KiB
Python
1055 lines
45 KiB
Python
"""CLI entry point ``rmgr``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from repo_manager.commands.rapp import add_rapp_parser
|
|
from repo_manager.commands.rapp import init as rapp_init
|
|
from repo_manager.commands.rapp import pin_image as rapp_pin_image
|
|
from repo_manager.commands.rapp import place as rapp_place
|
|
from repo_manager.commands.rapp import skeleton as rapp_skeleton
|
|
from repo_manager.commands.rapp import validate as rapp_validate
|
|
from repo_manager.commands.rapp import wrap as rapp_wrap
|
|
|
|
|
|
def _json_object(raw: str) -> dict:
|
|
try:
|
|
value = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise argparse.ArgumentTypeError(str(exc)) from exc
|
|
if not isinstance(value, dict):
|
|
raise argparse.ArgumentTypeError("value must be a JSON object")
|
|
return value
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
prog="rmgr",
|
|
description="Repo Manager CLI (helixforge.repo-manager)",
|
|
)
|
|
parser.add_argument("--version", action="store_true", help="Print package version")
|
|
sub = parser.add_subparsers(dest="command")
|
|
|
|
sub.add_parser("version", help="Print version")
|
|
sub.add_parser("dual-run-status", help="Show dual-run flags and mutation meter")
|
|
|
|
p_obs = sub.add_parser("observe", help="Observe repository + print snapshot JSON")
|
|
p_obs.add_argument("--path", default=".", help="Repository checkout path")
|
|
p_obs.add_argument("--slug", default=None, help="Override repo slug")
|
|
|
|
p_rec = sub.add_parser("reconcile", help="Rebuild local work-record index from files")
|
|
p_rec.add_argument("--path", default=".", help="Repository checkout path")
|
|
p_rec.add_argument("--slug", default=None)
|
|
p_rec.add_argument("--no-write-index", action="store_true", help="Do not write index file")
|
|
|
|
p_registrar = sub.add_parser(
|
|
"registrar-reconcile",
|
|
help="Assign missing State Hub UUIDs through a scoped on-demand registrar",
|
|
)
|
|
p_registrar.add_argument("--path", default=".", help="Repository checkout path")
|
|
p_registrar.add_argument(
|
|
"--api-base",
|
|
default=os.environ.get("STATE_HUB_API_BASE", "http://127.0.0.1:8000"),
|
|
help="Authoritative State Hub API base",
|
|
)
|
|
p_registrar.add_argument("--statehub-bin", default=None, help="Override statehub executable")
|
|
p_registrar.add_argument(
|
|
"--confirm-primary",
|
|
action="store_true",
|
|
help="Confirm that --api-base is the authoritative hub",
|
|
)
|
|
p_registrar.add_argument("--push", action="store_true", help="Push the registrar commit")
|
|
registrar_mode = p_registrar.add_mutually_exclusive_group()
|
|
registrar_mode.add_argument(
|
|
"--repair-workplan",
|
|
default=None,
|
|
metavar="ID",
|
|
help="Rebuild and verify one already-identified workplan projection",
|
|
)
|
|
registrar_mode.add_argument(
|
|
"--bootstrap-empty-projection",
|
|
action="store_true",
|
|
help="Rebuild all authoritative UUIDs after proving the repo projection is empty",
|
|
)
|
|
|
|
p_onboard = sub.add_parser(
|
|
"repo-onboard",
|
|
help="Onboard a repository into its authoritative State Hub from its classification file",
|
|
)
|
|
p_onboard.add_argument("--path", default=".", help="Repository checkout path")
|
|
p_onboard.add_argument("--slug", default=None, help="Override the repo slug (default: directory name)")
|
|
p_onboard.add_argument(
|
|
"--api-base",
|
|
default=os.environ.get("STATE_HUB_API_BASE"),
|
|
help="Authoritative State Hub API base URL (or set STATE_HUB_API_BASE). "
|
|
"Deliberately has no default: a silent localhost default is how "
|
|
"onboarding reached a cache instead of central for seven weeks.",
|
|
)
|
|
p_onboard.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Report what would change without writing to the hub",
|
|
)
|
|
|
|
p_cmd = sub.add_parser(
|
|
"update-task-status",
|
|
help="Command repo.work.update_task_status (file + git commit)",
|
|
)
|
|
p_cmd.add_argument("--path", default=".", help="Repository checkout path")
|
|
p_cmd.add_argument(
|
|
"--task-id",
|
|
required=True,
|
|
help="Canonical task id or State Hub task UUID",
|
|
)
|
|
p_cmd.add_argument(
|
|
"--status",
|
|
required=True,
|
|
choices=["wait", "todo", "progress", "done", "cancel"],
|
|
)
|
|
p_cmd.add_argument("--reason", default="rmgr CLI")
|
|
p_cmd.add_argument("--correlation-id", default=None)
|
|
p_cmd.add_argument("--idempotency-key", default=None)
|
|
p_cmd.add_argument("--expected-head-sha", default=None)
|
|
p_cmd.add_argument("--slug", default=None)
|
|
p_cmd.add_argument("--push", action="store_true", help="git push after commit (push-seal)")
|
|
p_cmd.add_argument(
|
|
"--no-commit",
|
|
action="store_true",
|
|
help="Patch file only (invalid as full applied evidence; for tests)",
|
|
)
|
|
|
|
p_wp = sub.add_parser("workplan", help="Governed file-backed workplan mutations")
|
|
wp_sub = p_wp.add_subparsers(dest="workplan_command")
|
|
p_wp_create = wp_sub.add_parser("create", help="Create a workplan file")
|
|
p_wp_create.add_argument("--path", default=".")
|
|
p_wp_create.add_argument("--workplan-id", required=True)
|
|
p_wp_create.add_argument("--title", required=True)
|
|
p_wp_create.add_argument("--goal", required=True)
|
|
p_wp_create.add_argument(
|
|
"--status",
|
|
choices=["proposed", "ready", "active", "blocked", "backlog", "finished", "archived"],
|
|
default="proposed",
|
|
)
|
|
p_wp_create.add_argument("--owner", default="codex")
|
|
p_wp_create.add_argument("--domain", default=None)
|
|
p_wp_create.add_argument("--topic-slug", default=None)
|
|
p_wp_create.add_argument("--filename", default=None)
|
|
|
|
p_wp_update = wp_sub.add_parser("update", help="Update workplan metadata or status")
|
|
p_wp_update.add_argument("--path", default=".")
|
|
p_wp_update.add_argument("--workplan-id", required=True)
|
|
p_wp_update.add_argument("--title", default=None)
|
|
p_wp_update.add_argument(
|
|
"--status",
|
|
choices=["proposed", "ready", "active", "blocked", "backlog", "finished", "archived"],
|
|
default=None,
|
|
)
|
|
p_wp_update.add_argument("--owner", default=None)
|
|
p_wp_update.add_argument("--domain", default=None)
|
|
p_wp_update.add_argument("--topic-slug", default=None)
|
|
|
|
p_wp_archive = wp_sub.add_parser(
|
|
"delete",
|
|
help="Recoverable delete: set archived and move to workplans/archived",
|
|
)
|
|
p_wp_archive.add_argument("--path", default=".")
|
|
p_wp_archive.add_argument("--workplan-id", required=True)
|
|
p_wp_archive.add_argument(
|
|
"--confirm",
|
|
action="store_true",
|
|
help="Confirm moving the workplan to the dated archive",
|
|
)
|
|
|
|
for wp_parser in (p_wp_create, p_wp_update, p_wp_archive):
|
|
wp_parser.add_argument("--reason", default="rmgr CLI")
|
|
wp_parser.add_argument("--correlation-id", default=None)
|
|
wp_parser.add_argument("--idempotency-key", default=None)
|
|
wp_parser.add_argument("--expected-head-sha", default=None)
|
|
wp_parser.add_argument("--slug", default=None)
|
|
wp_parser.add_argument("--push", action="store_true")
|
|
wp_parser.add_argument("--no-commit", action="store_true")
|
|
|
|
register_kinds = [
|
|
"sbom-inventory",
|
|
"repo-goals",
|
|
"upstream-contributions",
|
|
"technical-debt",
|
|
"extension-points",
|
|
"register-entries",
|
|
]
|
|
p_register = sub.add_parser("register", help="Repository-owned register spine")
|
|
register_sub = p_register.add_subparsers(dest="register_command")
|
|
p_reg_list = register_sub.add_parser("list", help="List indexed register entries")
|
|
p_reg_list.add_argument("--path", default=".")
|
|
p_reg_list.add_argument("--kind", choices=register_kinds, default=None)
|
|
p_reg_list.add_argument("--slug", default=None)
|
|
p_reg_put = register_sub.add_parser("put", help="Create or update a register entry")
|
|
p_reg_put.add_argument("--path", default=".")
|
|
p_reg_put.add_argument("--kind", required=True, choices=register_kinds)
|
|
p_reg_put.add_argument("--entry-id", required=True)
|
|
p_reg_put.add_argument("--title", default=None)
|
|
p_reg_put.add_argument("--status", default=None)
|
|
p_reg_put.add_argument("--data-json", type=_json_object, default=None)
|
|
p_reg_defer = register_sub.add_parser("defer", help="Defer a register entry")
|
|
p_reg_defer.add_argument("--path", default=".")
|
|
p_reg_defer.add_argument("--kind", required=True, choices=register_kinds)
|
|
p_reg_defer.add_argument("--entry-id", required=True)
|
|
p_reg_defer.add_argument("--status", default="deferred")
|
|
p_reg_note = register_sub.add_parser("note", help="Append a note to a register entry")
|
|
p_reg_note.add_argument("--path", default=".")
|
|
p_reg_note.add_argument("--kind", required=True, choices=register_kinds)
|
|
p_reg_note.add_argument("--entry-id", required=True)
|
|
p_reg_note.add_argument("--note", required=True)
|
|
p_reg_note.add_argument("--author", default="codex")
|
|
for register_parser in (p_reg_put, p_reg_defer, p_reg_note):
|
|
register_parser.add_argument("--reason", default="rmgr CLI")
|
|
register_parser.add_argument("--correlation-id", default=None)
|
|
register_parser.add_argument("--idempotency-key", default=None)
|
|
register_parser.add_argument("--expected-head-sha", default=None)
|
|
register_parser.add_argument("--slug", default=None)
|
|
register_parser.add_argument("--push", action="store_true")
|
|
register_parser.add_argument("--no-commit", action="store_true")
|
|
|
|
p_intake = sub.add_parser("intake", help="Repository-owned intake records")
|
|
intake_sub = p_intake.add_subparsers(dest="record_command")
|
|
p_intake_create = intake_sub.add_parser("create", help="Create an intake")
|
|
p_intake_create.add_argument("--path", default=".")
|
|
p_intake_create.add_argument("--record-id", required=True)
|
|
p_intake_create.add_argument("--title", required=True)
|
|
p_intake_create.add_argument("--status", default="open")
|
|
p_intake_create.add_argument("--data-json", type=_json_object, default=None)
|
|
p_intake_route = intake_sub.add_parser("route", help="Route an intake")
|
|
p_intake_route.add_argument("--path", default=".")
|
|
p_intake_route.add_argument("--record-id", required=True)
|
|
p_intake_route.add_argument("--route-to", required=True)
|
|
p_intake_note = intake_sub.add_parser("note", help="Append an intake note")
|
|
p_intake_note.add_argument("--path", default=".")
|
|
p_intake_note.add_argument("--record-id", required=True)
|
|
p_intake_note.add_argument("--note", required=True)
|
|
p_intake_note.add_argument("--author", default="codex")
|
|
p_intake_close = intake_sub.add_parser("close", help="Close an intake")
|
|
p_intake_close.add_argument("--path", default=".")
|
|
p_intake_close.add_argument("--record-id", required=True)
|
|
p_intake_close.add_argument("--outcome", default=None)
|
|
|
|
p_decision = sub.add_parser("decision", help="Repository-owned decision records")
|
|
decision_sub = p_decision.add_subparsers(dest="record_command")
|
|
p_decision_create = decision_sub.add_parser("create", help="Create a decision")
|
|
p_decision_create.add_argument("--path", default=".")
|
|
p_decision_create.add_argument("--record-id", required=True)
|
|
p_decision_create.add_argument("--title", required=True)
|
|
p_decision_create.add_argument("--status", default="open")
|
|
p_decision_create.add_argument("--data-json", type=_json_object, default=None)
|
|
p_decision_update = decision_sub.add_parser("update", help="Update a decision")
|
|
p_decision_update.add_argument("--path", default=".")
|
|
p_decision_update.add_argument("--record-id", required=True)
|
|
p_decision_update.add_argument("--title", default=None)
|
|
p_decision_update.add_argument("--status", default=None)
|
|
p_decision_update.add_argument("--data-json", type=_json_object, default=None)
|
|
p_decision_resolve = decision_sub.add_parser("resolve", help="Resolve a decision")
|
|
p_decision_resolve.add_argument("--path", default=".")
|
|
p_decision_resolve.add_argument("--record-id", required=True)
|
|
p_decision_resolve.add_argument("--rationale", required=True)
|
|
p_decision_resolve.add_argument("--decided-by", required=True)
|
|
|
|
for record_parser in (
|
|
p_intake_create,
|
|
p_intake_route,
|
|
p_intake_note,
|
|
p_intake_close,
|
|
p_decision_create,
|
|
p_decision_update,
|
|
p_decision_resolve,
|
|
):
|
|
record_parser.add_argument("--reason", default="rmgr CLI")
|
|
record_parser.add_argument("--correlation-id", default=None)
|
|
record_parser.add_argument("--idempotency-key", default=None)
|
|
record_parser.add_argument("--expected-head-sha", default=None)
|
|
record_parser.add_argument("--slug", default=None)
|
|
record_parser.add_argument("--push", action="store_true")
|
|
record_parser.add_argument("--no-commit", action="store_true")
|
|
|
|
add_rapp_parser(sub)
|
|
|
|
p_conf = sub.add_parser("conform", help="Check a repository against flavor standards")
|
|
p_conf.add_argument("--path", default=".", help="Repository checkout path")
|
|
p_conf.add_argument("--slug", default=None)
|
|
|
|
p_pref = sub.add_parser(
|
|
"prefix-uniqueness",
|
|
help="Detect shared workplan prefixes and reused identifiers (ADR-007)",
|
|
)
|
|
p_pref.add_argument("--root", default=".", help="Fleet root or a single repository")
|
|
p_pref.add_argument("--registry", default=None, help="Override prefix registry YAML")
|
|
|
|
p_scaf = sub.add_parser("scaffold", help="Create flavor-correct repository baseline files")
|
|
p_scaf.add_argument("--path", required=True)
|
|
p_scaf.add_argument("--flavor", required=True, choices=["experimental", "research", "project", "tooling", "product", "business"])
|
|
p_scaf.add_argument("--slug", default=None)
|
|
p_scaf.add_argument("--domain", default="infotech")
|
|
p_scaf.add_argument("--wp-prefix", default=None)
|
|
p_scaf.add_argument("--force", action="store_true")
|
|
p_scaf.add_argument("--no-commit", action="store_true")
|
|
|
|
p_provenance = sub.add_parser(
|
|
"assistant-provenance",
|
|
help="Install or report coding-assistant commit provenance",
|
|
)
|
|
provenance_sub = p_provenance.add_subparsers(dest="provenance_command")
|
|
p_prov_install = provenance_sub.add_parser("install", help="Set global core.hooksPath")
|
|
p_prov_install.add_argument(
|
|
"--hooks-path",
|
|
default=str(Path(__file__).resolve().parents[2] / ".githooks"),
|
|
)
|
|
p_prov_report = provenance_sub.add_parser("report", help="Report trailers from Git history")
|
|
p_prov_report.add_argument("--path", default=".")
|
|
p_prov_report.add_argument("--rev", default="HEAD")
|
|
p_prov_report.add_argument("--max-count", type=int, default=None)
|
|
|
|
p_identifier = sub.add_parser("identifier", help="Deterministic work-record identifiers")
|
|
identifier_sub = p_identifier.add_subparsers(dest="identifier_command")
|
|
p_id_derive = identifier_sub.add_parser("derive", help="Derive one UUIDv5")
|
|
p_id_derive.add_argument("--namespace", default=None, help="Override declared fleet namespace")
|
|
p_id_derive.add_argument("--record-id", required=True)
|
|
p_id_preflight = identifier_sub.add_parser("preflight", help="Scan live identifier collisions")
|
|
p_id_preflight.add_argument("--root", default=".")
|
|
p_id_plan = identifier_sub.add_parser(
|
|
"migration-plan",
|
|
help="Emit a non-mutating, per-repository old-to-derived UUID plan",
|
|
)
|
|
p_id_plan.add_argument("--root", default=".")
|
|
p_id_plan.add_argument("--namespace", default=None, help="Override declared fleet namespace")
|
|
p_id_plan.add_argument("--output", default=None, help="Write the provenance mapping as JSON")
|
|
p_id_plan.add_argument("--force", action="store_true", help="Replace an existing --output file")
|
|
p_id_verify = identifier_sub.add_parser(
|
|
"migration-verify",
|
|
help="Verify a sealed migration plan against current repository sources",
|
|
)
|
|
p_id_verify.add_argument("--plan", required=True)
|
|
p_id_verify.add_argument("--repo", default=None, help="Verify one repository atomic unit")
|
|
p_id_batch = identifier_sub.add_parser(
|
|
"migration-batch-plan",
|
|
help="Pin a clean synchronized repository batch for explicit approval",
|
|
)
|
|
p_id_batch.add_argument("--plan", required=True)
|
|
p_id_batch.add_argument("--repo", action="append", required=True, dest="repos")
|
|
p_id_batch.add_argument(
|
|
"--projection-api-base",
|
|
action="append",
|
|
default=[],
|
|
dest="projection_api_bases",
|
|
help="Require current UUID=200 and derived UUID=404 on this projection",
|
|
)
|
|
p_id_batch.add_argument("--output", default=None)
|
|
p_id_batch.add_argument("--force", action="store_true")
|
|
p_id_batch_verify = identifier_sub.add_parser(
|
|
"migration-batch-verify",
|
|
help="Verify a saved batch seal and repeat source/Git preflight",
|
|
)
|
|
p_id_batch_verify.add_argument("--plan", required=True)
|
|
p_id_batch_verify.add_argument("--batch", required=True)
|
|
p_id_files = identifier_sub.add_parser(
|
|
"migration-files",
|
|
help="Validate or execute one repository's sealed UUID file rewrite",
|
|
)
|
|
p_id_files.add_argument("--plan", required=True)
|
|
p_id_files.add_argument("--repo", required=True)
|
|
p_id_files.add_argument("--confirm-plan-sha256", required=True)
|
|
p_id_files.add_argument(
|
|
"--direction", choices=["forward", "reverse"], default="forward"
|
|
)
|
|
p_id_files.add_argument(
|
|
"--execute",
|
|
action="store_true",
|
|
help="Write files; without this flag only validate and report",
|
|
)
|
|
|
|
p_sbom = sub.add_parser(
|
|
"sbom",
|
|
help="Deprecated compatibility commands delegated to SBOM Nexus",
|
|
)
|
|
sbom_sub = p_sbom.add_subparsers(dest="sbom_command")
|
|
p_sbom_scan = sbom_sub.add_parser(
|
|
"scan",
|
|
help="Run a local non-authoritative preview through the SBOM Nexus CLI",
|
|
)
|
|
p_sbom_scan.add_argument("--path", default=".")
|
|
p_sbom_scan.add_argument("--slug", default=None)
|
|
p_sbom_scan.add_argument("--output", default=None, help="Write the derived snapshot as JSON")
|
|
p_sbom_scan.add_argument("--force", action="store_true", help="Replace an existing --output file")
|
|
p_sbom_report = sbom_sub.add_parser(
|
|
"licence-report",
|
|
help="Preview licences locally without persisting or advancing Nexus state",
|
|
)
|
|
p_sbom_report.add_argument("--path", default=".")
|
|
p_sbom_report.add_argument("--slug", default=None)
|
|
p_sbom_source = sbom_sub.add_parser(
|
|
"source-ref",
|
|
help="Resolve a controlled Forgejo source reference and optionally project it",
|
|
)
|
|
p_sbom_source.add_argument("--path", default=".")
|
|
p_sbom_source.add_argument("--slug", default=None)
|
|
p_sbom_source.add_argument("--remote", default="origin")
|
|
p_sbom_source.add_argument(
|
|
"--forgejo-base", default="https://forgejo.coulomb.social"
|
|
)
|
|
p_sbom_source.add_argument("--project", action="store_true")
|
|
p_sbom_source.add_argument("--confirm-authoritative", action="store_true")
|
|
|
|
p_authority = sub.add_parser("authority", help="Resolve the one authoritative record owner")
|
|
authority_sub = p_authority.add_subparsers(dest="authority_command")
|
|
p_authority_route = authority_sub.add_parser("route", help="Resolve or verify an authority route")
|
|
p_authority_route.add_argument("--record-type", required=True)
|
|
p_authority_route.add_argument("--repo-slug", default=None)
|
|
p_authority_route.add_argument("--domain-slug", default=None)
|
|
p_authority_route.add_argument("--claimed-owner", default=None)
|
|
|
|
p_cache = sub.add_parser("cache", help="Inspect and rebuild advisory repository caches")
|
|
cache_sub = p_cache.add_subparsers(dest="cache_command")
|
|
p_cache_status = cache_sub.add_parser("status", help="Report cache age and source drift")
|
|
p_cache_status.add_argument("--path", default=".")
|
|
p_cache_rebuild = cache_sub.add_parser("rebuild", help="Rebuild the local index from files")
|
|
p_cache_rebuild.add_argument("--path", default=".")
|
|
p_cache_rebuild.add_argument("--slug", default=None)
|
|
p_cache_export = cache_sub.add_parser(
|
|
"export-closed",
|
|
help="Export unbound closed State Hub records before cache replacement",
|
|
)
|
|
p_cache_export.add_argument(
|
|
"--api-base",
|
|
default=os.environ.get("STATE_HUB_API_BASE", "http://127.0.0.1:8000"),
|
|
)
|
|
p_cache_export.add_argument("--output", required=True)
|
|
p_cache_export.add_argument("--force", action="store_true")
|
|
|
|
p_workload = sub.add_parser(
|
|
"workload",
|
|
help="Index or resolve authoritative rapp workload declarations",
|
|
)
|
|
workload_sub = p_workload.add_subparsers(dest="workload_command")
|
|
p_workload_index = workload_sub.add_parser(
|
|
"index", help="Index workload identities from rapp declarations"
|
|
)
|
|
p_workload_index.add_argument("--root", default=".", help="Fleet root or one rapp repo")
|
|
p_workload_resolve = workload_sub.add_parser(
|
|
"resolve", help="Resolve one explicit workload reference without inference"
|
|
)
|
|
p_workload_resolve.add_argument("--root", default=".", help="Fleet root or one rapp repo")
|
|
p_workload_resolve.add_argument("--rapp-id", required=True)
|
|
p_workload_resolve.add_argument("--name", required=True)
|
|
p_workload_resolve.add_argument("--deployable", default=None)
|
|
|
|
p_owner_interface = sub.add_parser(
|
|
"owner-interface",
|
|
help="Inspect validated owner-consumable task interfaces",
|
|
)
|
|
owner_interface_sub = p_owner_interface.add_subparsers(dest="owner_interface_command")
|
|
p_owner_interface_validate = owner_interface_sub.add_parser(
|
|
"validate", help="Validate and return owner task interfaces"
|
|
)
|
|
p_owner_interface_validate.add_argument(
|
|
"--path", default="interfaces", help="One interface YAML or an interface directory"
|
|
)
|
|
p_owner_interface_validate.add_argument(
|
|
"--owner", default=None, help="Return interfaces for this target repo or owner agent"
|
|
)
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.version or args.command in (None, "version"):
|
|
from repo_manager import __version__
|
|
|
|
print(__version__)
|
|
return 0
|
|
|
|
if args.command == "dual-run-status":
|
|
from repo_manager.dual_run import flags_status
|
|
|
|
print(json.dumps(flags_status(), indent=2))
|
|
return 0
|
|
|
|
if args.command == "observe":
|
|
from repo_manager.observe import observe_repository
|
|
|
|
snap, _idx = observe_repository(Path(args.path), slug=args.slug)
|
|
print(json.dumps(snap, indent=2))
|
|
return 0
|
|
|
|
if args.command == "reconcile":
|
|
from repo_manager.dual_run import record_mutation
|
|
from repo_manager.index_store import append_event, save_index
|
|
from repo_manager.observe import observe_repository
|
|
|
|
root = Path(args.path)
|
|
snap, index = observe_repository(root, slug=args.slug)
|
|
append_event(
|
|
index,
|
|
{
|
|
"type": "repo.reconciled",
|
|
"workplan_count": snap["index"]["workplan_count"],
|
|
"task_count": snap["index"]["task_count"],
|
|
"source": "repo-manager",
|
|
},
|
|
)
|
|
record_mutation(
|
|
source="repo-manager",
|
|
kind="reconcile",
|
|
repo_slug=snap.get("slug"),
|
|
detail=snap["index"],
|
|
)
|
|
if not args.no_write_index:
|
|
path = save_index(index)
|
|
print(json.dumps({"ok": True, "index_path": str(path), "snapshot": snap}, indent=2))
|
|
else:
|
|
print(json.dumps({"ok": True, "snapshot": snap, "index": index.to_dict()}, indent=2))
|
|
return 0
|
|
|
|
if args.command == "registrar-reconcile":
|
|
from repo_manager.commands.registrar_reconcile import registrar_reconcile
|
|
|
|
result = registrar_reconcile(
|
|
Path(args.path),
|
|
api_base=args.api_base,
|
|
statehub_bin=args.statehub_bin,
|
|
confirm_primary=args.confirm_primary,
|
|
push=args.push,
|
|
repair_workplan=args.repair_workplan,
|
|
bootstrap_empty_projection=args.bootstrap_empty_projection,
|
|
)
|
|
print(json.dumps(result.to_dict(), indent=2))
|
|
return 0 if result.status in {"applied", "noop"} else 1
|
|
|
|
if args.command == "repo-onboard":
|
|
from repo_manager.commands.repo_onboard import OnboardError, onboard_repo
|
|
|
|
if not args.api_base:
|
|
print(
|
|
"ERROR: --api-base is required (or set STATE_HUB_API_BASE). "
|
|
"Point it at the authoritative hub, not a local cache.",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
try:
|
|
report = onboard_repo(
|
|
Path(args.path),
|
|
api_base=args.api_base,
|
|
dry_run=args.dry_run,
|
|
slug=args.slug,
|
|
)
|
|
except OnboardError as exc:
|
|
print(json.dumps({"status": "blocked", "error": str(exc)}, indent=2))
|
|
return 1
|
|
print(json.dumps(report, indent=2))
|
|
return 0
|
|
|
|
if args.command == "update-task-status":
|
|
from repo_manager.commands.task_status import update_task_status
|
|
|
|
result = update_task_status(
|
|
Path(args.path),
|
|
args.task_id,
|
|
args.status,
|
|
correlation_id=args.correlation_id,
|
|
reason=args.reason,
|
|
commit=not args.no_commit,
|
|
push=args.push,
|
|
expected_head_sha=args.expected_head_sha,
|
|
idempotency_key=args.idempotency_key,
|
|
repo_slug=args.slug,
|
|
)
|
|
print(json.dumps(result.to_dict(), indent=2))
|
|
return 0 if result.status == "applied" else 1
|
|
|
|
if args.command == "workplan":
|
|
from repo_manager.commands.workplan import mutate_workplan
|
|
|
|
if not args.workplan_command:
|
|
p_wp.print_help()
|
|
return 2
|
|
operation = "archive" if args.workplan_command == "delete" else args.workplan_command
|
|
result = mutate_workplan(
|
|
Path(args.path),
|
|
args.workplan_id,
|
|
operation=operation,
|
|
title=getattr(args, "title", None),
|
|
goal=getattr(args, "goal", None),
|
|
status=getattr(args, "status", None),
|
|
owner=getattr(args, "owner", None),
|
|
topic_slug=getattr(args, "topic_slug", None),
|
|
domain=getattr(args, "domain", None),
|
|
filename=getattr(args, "filename", None),
|
|
confirm_archive=getattr(args, "confirm", False),
|
|
correlation_id=args.correlation_id,
|
|
reason=args.reason,
|
|
commit=not args.no_commit,
|
|
push=args.push,
|
|
expected_head_sha=args.expected_head_sha,
|
|
idempotency_key=args.idempotency_key,
|
|
repo_slug=args.slug,
|
|
)
|
|
print(json.dumps(result.to_dict(), indent=2))
|
|
return 0 if result.status == "applied" else 1
|
|
|
|
if args.command == "register":
|
|
if not args.register_command:
|
|
p_register.print_help()
|
|
return 2
|
|
if args.register_command == "list":
|
|
from repo_manager.observe import observe_repository
|
|
|
|
_snapshot, index = observe_repository(Path(args.path), slug=args.slug)
|
|
prefix = f"register:{args.kind}" if args.kind else "register:"
|
|
entries = [r.__dict__ for r in index.work_records if r.kind.startswith(prefix)]
|
|
print(json.dumps({"ok": True, "entries": entries}, indent=2))
|
|
return 0
|
|
|
|
from repo_manager.commands.register import mutate_register_entry
|
|
|
|
result = mutate_register_entry(
|
|
Path(args.path),
|
|
args.kind,
|
|
args.entry_id,
|
|
operation="upsert" if args.register_command == "put" else args.register_command,
|
|
title=getattr(args, "title", None),
|
|
status=getattr(args, "status", None),
|
|
data=getattr(args, "data_json", None),
|
|
note=getattr(args, "note", None),
|
|
note_author=getattr(args, "author", None),
|
|
correlation_id=args.correlation_id,
|
|
reason=args.reason,
|
|
commit=not args.no_commit,
|
|
push=args.push,
|
|
expected_head_sha=args.expected_head_sha,
|
|
idempotency_key=args.idempotency_key,
|
|
repo_slug=args.slug,
|
|
)
|
|
print(json.dumps(result.to_dict(), indent=2))
|
|
return 0 if result.status == "applied" else 1
|
|
|
|
if args.command in {"intake", "decision"}:
|
|
selected = p_intake if args.command == "intake" else p_decision
|
|
if not args.record_command:
|
|
selected.print_help()
|
|
return 2
|
|
from repo_manager.commands.record import mutate_record
|
|
|
|
result = mutate_record(
|
|
Path(args.path),
|
|
args.command,
|
|
args.record_id,
|
|
operation=args.record_command,
|
|
title=getattr(args, "title", None),
|
|
status=getattr(args, "status", None),
|
|
data=getattr(args, "data_json", None),
|
|
route_to=getattr(args, "route_to", None),
|
|
note=getattr(args, "note", None),
|
|
author=getattr(args, "author", None),
|
|
outcome=getattr(args, "outcome", None),
|
|
rationale=getattr(args, "rationale", None),
|
|
decided_by=getattr(args, "decided_by", None),
|
|
correlation_id=args.correlation_id,
|
|
reason=args.reason,
|
|
commit=not args.no_commit,
|
|
push=args.push,
|
|
expected_head_sha=args.expected_head_sha,
|
|
idempotency_key=args.idempotency_key,
|
|
repo_slug=args.slug,
|
|
)
|
|
print(json.dumps(result.to_dict(), indent=2))
|
|
return 0 if result.status == "applied" else 1
|
|
|
|
if args.command == "rapp":
|
|
if args.rapp_command == "init":
|
|
result = rapp_init(
|
|
Path(args.path),
|
|
app=args.app,
|
|
ownership_repo=args.ownership_repo,
|
|
rail=args.rail,
|
|
classification=args.classification,
|
|
criticality=args.criticality,
|
|
package_type=args.package_type,
|
|
purpose=args.purpose,
|
|
force=args.force,
|
|
)
|
|
elif args.rapp_command == "validate":
|
|
result = rapp_validate(
|
|
Path(args.path),
|
|
family_root=Path(args.family_root) if args.family_root else None,
|
|
)
|
|
elif args.rapp_command == "skeleton":
|
|
result = rapp_skeleton(
|
|
Path(args.path),
|
|
from_app=Path(args.from_app),
|
|
app=args.app,
|
|
package_type=args.package_type,
|
|
force=args.force,
|
|
dedicated_postgres=args.dedicated_postgres,
|
|
)
|
|
elif args.rapp_command == "wrap":
|
|
result = rapp_wrap(
|
|
Path(args.path),
|
|
app=args.app,
|
|
ownership_repo=args.ownership_repo,
|
|
from_app=Path(args.from_app),
|
|
rail=args.rail,
|
|
classification=args.classification,
|
|
criticality=args.criticality,
|
|
package_type=args.package_type,
|
|
purpose=args.purpose,
|
|
force=args.force,
|
|
dedicated_postgres=args.dedicated_postgres,
|
|
family_root=Path(args.family_root) if args.family_root else None,
|
|
)
|
|
elif args.rapp_command == "place":
|
|
result = rapp_place(
|
|
Path(args.path),
|
|
reef=args.reef,
|
|
family_root=Path(args.family_root) if args.family_root else None,
|
|
)
|
|
elif args.rapp_command == "pin-image":
|
|
result = rapp_pin_image(Path(args.path), args.digest)
|
|
else:
|
|
parser.print_help()
|
|
return 2
|
|
print(json.dumps(result, indent=2))
|
|
return 0 if result.get("ok") else 1
|
|
|
|
if args.command == "conform":
|
|
from repo_manager.standards import check_repository
|
|
|
|
report = check_repository(Path(args.path), slug=args.slug)
|
|
print(json.dumps(report.to_dict(), indent=2))
|
|
return 0 if report.ok else 1
|
|
|
|
if args.command == "prefix-uniqueness":
|
|
from repo_manager.prefix_registry import scan_prefixes
|
|
|
|
registry = Path(args.registry) if args.registry else None
|
|
report = scan_prefixes(Path(args.root), registry_path=registry)
|
|
print(json.dumps(report, indent=2))
|
|
return 0 if report.get("ok") else 1
|
|
|
|
if args.command == "scaffold":
|
|
from repo_manager.commands.scaffold import scaffold_repository
|
|
|
|
result = scaffold_repository(
|
|
Path(args.path),
|
|
flavor=args.flavor,
|
|
slug=args.slug,
|
|
domain=args.domain,
|
|
wp_prefix=args.wp_prefix,
|
|
force=args.force,
|
|
commit=not args.no_commit,
|
|
)
|
|
print(json.dumps(result.to_dict(), indent=2))
|
|
return 0 if result.status == "applied" else 1
|
|
|
|
if args.command == "assistant-provenance":
|
|
if not args.provenance_command:
|
|
p_provenance.print_help()
|
|
return 2
|
|
from repo_manager.provenance import assistant_report, install_hook
|
|
|
|
if args.provenance_command == "install":
|
|
result = install_hook(Path(args.hooks_path))
|
|
else:
|
|
result = assistant_report(Path(args.path), rev=args.rev, max_count=args.max_count)
|
|
print(json.dumps(result, indent=2))
|
|
return 0 if result.get("ok") else 1
|
|
|
|
if args.command == "identifier":
|
|
if not args.identifier_command:
|
|
p_identifier.print_help()
|
|
return 2
|
|
from repo_manager.identifiers import (
|
|
derive_work_record_uuid,
|
|
load_fleet_namespace,
|
|
migrate_repository_identifier_files,
|
|
plan_identifier_migration,
|
|
plan_identifier_migration_batch,
|
|
scan_live_identifier_collisions,
|
|
verify_identifier_migration_batch,
|
|
verify_identifier_migration_plan,
|
|
)
|
|
|
|
namespace = getattr(args, "namespace", None) or load_fleet_namespace()
|
|
|
|
if args.identifier_command == "derive":
|
|
try:
|
|
derived = derive_work_record_uuid(namespace, args.record_id)
|
|
except ValueError as exc:
|
|
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
return 1
|
|
result = {
|
|
"ok": True,
|
|
"namespace": namespace,
|
|
"record_id": args.record_id,
|
|
"uuid": str(derived),
|
|
}
|
|
elif args.identifier_command == "preflight":
|
|
result = scan_live_identifier_collisions(Path(args.root))
|
|
elif args.identifier_command == "migration-verify":
|
|
try:
|
|
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
|
|
if not isinstance(plan, dict):
|
|
raise TypeError("migration plan must be a JSON object")
|
|
result = verify_identifier_migration_plan(plan, repo_slug=args.repo)
|
|
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
return 1
|
|
elif args.identifier_command == "migration-files":
|
|
try:
|
|
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
|
|
if not isinstance(plan, dict):
|
|
raise TypeError("migration plan must be a JSON object")
|
|
result = migrate_repository_identifier_files(
|
|
plan,
|
|
repo_slug=args.repo,
|
|
confirm_plan_sha256=args.confirm_plan_sha256,
|
|
direction=args.direction,
|
|
execute=args.execute,
|
|
)
|
|
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
return 1
|
|
elif args.identifier_command == "migration-batch-plan":
|
|
try:
|
|
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
|
|
if not isinstance(plan, dict):
|
|
raise TypeError("migration plan must be a JSON object")
|
|
result = plan_identifier_migration_batch(
|
|
plan,
|
|
repo_slugs=args.repos,
|
|
projection_api_bases=args.projection_api_bases,
|
|
)
|
|
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
return 1
|
|
if args.output:
|
|
output = Path(args.output)
|
|
if output.exists() and not args.force:
|
|
print(
|
|
json.dumps(
|
|
{"ok": False, "error": f"output exists: {output}; use --force to replace"},
|
|
indent=2,
|
|
)
|
|
)
|
|
return 1
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
|
elif args.identifier_command == "migration-batch-verify":
|
|
try:
|
|
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
|
|
batch = json.loads(Path(args.batch).read_text(encoding="utf-8"))
|
|
if not isinstance(plan, dict) or not isinstance(batch, dict):
|
|
raise TypeError("migration plan and batch must be JSON objects")
|
|
result = verify_identifier_migration_batch(batch, plan=plan)
|
|
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
return 1
|
|
else:
|
|
try:
|
|
result = plan_identifier_migration(Path(args.root), namespace)
|
|
except ValueError as exc:
|
|
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
return 1
|
|
if args.output:
|
|
output = Path(args.output)
|
|
if output.exists() and not args.force:
|
|
print(
|
|
json.dumps(
|
|
{"ok": False, "error": f"output exists: {output}; use --force to replace"},
|
|
indent=2,
|
|
)
|
|
)
|
|
return 1
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
|
print(json.dumps(result, indent=2))
|
|
return 0 if result.get("ok") else 1
|
|
|
|
if args.command == "sbom":
|
|
if not args.sbom_command:
|
|
p_sbom.print_help()
|
|
return 2
|
|
if args.sbom_command == "source-ref":
|
|
from repo_manager.sbom_client import (
|
|
SBOMNexusClient,
|
|
SBOMNexusConfig,
|
|
SBOMServiceError,
|
|
)
|
|
from repo_manager.source_ref import ForgejoSourceResolver
|
|
|
|
resolution = ForgejoSourceResolver(base_url=args.forgejo_base).resolve(
|
|
Path(args.path),
|
|
repo_slug=args.slug,
|
|
remote_name=args.remote,
|
|
)
|
|
if not args.project:
|
|
print(json.dumps(resolution, indent=2))
|
|
return 0 if resolution.get("supported") else 1
|
|
if not args.confirm_authoritative:
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"ok": False,
|
|
"error": "--project requires --confirm-authoritative",
|
|
"resolution": resolution,
|
|
},
|
|
indent=2,
|
|
)
|
|
)
|
|
return 2
|
|
if not resolution.get("supported"):
|
|
print(json.dumps(resolution, indent=2))
|
|
return 1
|
|
try:
|
|
client = SBOMNexusClient(SBOMNexusConfig.from_environment())
|
|
projection = client.upsert_repository(
|
|
resolution["repo_slug"],
|
|
nexus_checkout_path=None,
|
|
source_ref=resolution["source_ref"],
|
|
)
|
|
except (ValueError, SBOMServiceError) as exc:
|
|
error = exc.to_dict() if isinstance(exc, SBOMServiceError) else {"code": "config_error"}
|
|
print(
|
|
json.dumps(
|
|
{"ok": False, "error": error, "resolution": resolution},
|
|
indent=2,
|
|
)
|
|
)
|
|
return 1
|
|
print(
|
|
json.dumps(
|
|
{"ok": True, "resolution": resolution, "projection": projection},
|
|
indent=2,
|
|
)
|
|
)
|
|
return 0
|
|
from repo_manager.sbom_client import (
|
|
licence_report_from_snapshot,
|
|
scan_repository_via_nexus,
|
|
)
|
|
|
|
snapshot = scan_repository_via_nexus(Path(args.path), slug=args.slug)
|
|
result = (
|
|
snapshot
|
|
if args.sbom_command == "scan"
|
|
else licence_report_from_snapshot(snapshot)
|
|
)
|
|
if args.sbom_command == "scan" and args.output:
|
|
output = Path(args.output)
|
|
if output.exists() and not args.force:
|
|
print(
|
|
json.dumps(
|
|
{"ok": False, "error": f"output exists: {output}; use --force to replace"},
|
|
indent=2,
|
|
)
|
|
)
|
|
return 1
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8")
|
|
print(json.dumps(result, indent=2))
|
|
return 0 if result.get("ok") else 1
|
|
|
|
if args.command == "authority":
|
|
if not args.authority_command:
|
|
p_authority.print_help()
|
|
return 2
|
|
from repo_manager.authority import AuthorityError, resolve_record_authority
|
|
|
|
try:
|
|
result = resolve_record_authority(
|
|
args.record_type,
|
|
repo_slug=args.repo_slug,
|
|
domain_slug=args.domain_slug,
|
|
claimed_owner=args.claimed_owner,
|
|
)
|
|
except AuthorityError as exc:
|
|
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
return 1
|
|
print(json.dumps({"ok": True, **result}, indent=2))
|
|
return 0
|
|
|
|
if args.command == "cache":
|
|
if not args.cache_command:
|
|
p_cache.print_help()
|
|
return 2
|
|
from repo_manager.cache import cache_status, fetch_closed_provenance
|
|
|
|
if args.cache_command == "status":
|
|
result = cache_status(Path(args.path))
|
|
elif args.cache_command == "rebuild":
|
|
from repo_manager.index_store import save_index
|
|
from repo_manager.observe import observe_repository
|
|
|
|
snapshot, index = observe_repository(Path(args.path), slug=args.slug)
|
|
index_path = save_index(index)
|
|
result = {
|
|
"ok": True,
|
|
"rebuilt": True,
|
|
"index_path": str(index_path),
|
|
"record_count": snapshot["index"]["record_count"],
|
|
"cache": cache_status(Path(args.path)),
|
|
}
|
|
else:
|
|
output = Path(args.output)
|
|
if output.exists() and not args.force:
|
|
print(
|
|
json.dumps(
|
|
{"ok": False, "error": f"output exists: {output}; use --force to replace"},
|
|
indent=2,
|
|
)
|
|
)
|
|
return 1
|
|
try:
|
|
result = fetch_closed_provenance(args.api_base)
|
|
except (OSError, TypeError, ValueError) as exc:
|
|
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
return 1
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
|
result = {key: value for key, value in result.items() if key != "rows"}
|
|
result["output"] = str(output)
|
|
print(json.dumps(result, indent=2))
|
|
return 0 if result.get("ok") else 1
|
|
|
|
if args.command == "workload":
|
|
if not args.workload_command:
|
|
p_workload.print_help()
|
|
return 2
|
|
from repo_manager.workloads import index_workloads, resolve_workload
|
|
|
|
if args.workload_command == "index":
|
|
result = index_workloads(Path(args.root))
|
|
else:
|
|
result = resolve_workload(
|
|
Path(args.root),
|
|
rapp_id=args.rapp_id,
|
|
name=args.name,
|
|
deployable=args.deployable,
|
|
)
|
|
print(json.dumps(result, indent=2))
|
|
return 0 if result.get("ok") else 1
|
|
|
|
if args.command == "owner-interface":
|
|
if not args.owner_interface_command:
|
|
p_owner_interface.print_help()
|
|
return 2
|
|
from repo_manager.owner_interfaces import validate_owner_interfaces
|
|
|
|
result = validate_owner_interfaces(Path(args.path), owner=args.owner)
|
|
print(json.dumps(result, indent=2))
|
|
return 0 if result.get("ok") else 1
|
|
|
|
parser.print_help()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|