Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
961 lines
32 KiB
Python
961 lines
32 KiB
Python
"""Non-interactive State Hub repository-rename orchestration CLI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import stat
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
from repository_rename_workplan import (
|
|
WorkplanGenerationError,
|
|
build_generation_snapshot,
|
|
content_checksum,
|
|
default_output_path,
|
|
render_workplan,
|
|
write_workplan_exclusive,
|
|
)
|
|
|
|
|
|
CLI_SCHEMA_VERSION = "state-hub.repository-rename-cli.v1"
|
|
DEFAULT_API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000")
|
|
FORWARD_PHASES = (
|
|
"forge-renamed",
|
|
"statehub-rebound",
|
|
"source-synced",
|
|
"consumers-verified",
|
|
"completed",
|
|
)
|
|
NEXT_PHASE = {
|
|
"preflighted": "forge-renamed",
|
|
"forge-renamed": "statehub-rebound",
|
|
"statehub-rebound": "source-synced",
|
|
"source-synced": "consumers-verified",
|
|
"consumers-verified": "completed",
|
|
}
|
|
_HEADERS = {"X-StateHub-Component": "state-hub.repository-rename-cli"}
|
|
_SENSITIVE_KEYS = {
|
|
"authorization",
|
|
"cookie",
|
|
"set-cookie",
|
|
"password",
|
|
"secret",
|
|
"api-key",
|
|
"api_key",
|
|
"access-token",
|
|
"access_token",
|
|
"refresh-token",
|
|
"refresh_token",
|
|
"credential",
|
|
}
|
|
|
|
|
|
class RenameCLIError(RuntimeError):
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
*,
|
|
code: str = "rename_cli_error",
|
|
status_code: int | None = None,
|
|
details: Any = None,
|
|
) -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
self.status_code = status_code
|
|
self.details = details
|
|
|
|
|
|
def _redact_text(value: str) -> str:
|
|
value = re.sub(
|
|
r"(https?://)[^/@\s]+:[^/@\s]+@",
|
|
r"\1[REDACTED]@",
|
|
value,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
value = re.sub(
|
|
r"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+",
|
|
r"\1 [REDACTED]",
|
|
value,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
value = re.sub(
|
|
r"((?:^|[\s?&])[^=&#\s]*(?:api[_-]?key|authorization|password|secret|signature|token|credential)"
|
|
r"[^=&#\s]*=)[^&#\s]+",
|
|
r"\1[REDACTED]",
|
|
value,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
return value
|
|
|
|
|
|
def _public(value: Any, *, redact_preflight_token: bool = True) -> Any:
|
|
if isinstance(value, dict):
|
|
result: dict[str, Any] = {}
|
|
for key, item in value.items():
|
|
normalized = key.lower()
|
|
if normalized in _SENSITIVE_KEYS or any(
|
|
marker in normalized
|
|
for marker in ("password", "authorization", "credential")
|
|
):
|
|
result[key] = "[REDACTED]"
|
|
elif key == "preflight_token" and redact_preflight_token and item:
|
|
result[key] = "[REDACTED: use --output preflight file]"
|
|
else:
|
|
result[key] = _public(
|
|
item, redact_preflight_token=redact_preflight_token
|
|
)
|
|
return result
|
|
if isinstance(value, list):
|
|
return [
|
|
_public(item, redact_preflight_token=redact_preflight_token)
|
|
for item in value
|
|
]
|
|
if isinstance(value, str):
|
|
return _redact_text(value)
|
|
return value
|
|
|
|
|
|
def _api_request(
|
|
api_base: str,
|
|
method: str,
|
|
path: str,
|
|
body: dict[str, Any] | None = None,
|
|
*,
|
|
expected: type | tuple[type, ...] = dict,
|
|
) -> Any:
|
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
headers = dict(_HEADERS)
|
|
if data is not None:
|
|
headers["Content-Type"] = "application/json"
|
|
request = urllib.request.Request(
|
|
api_base.rstrip("/") + path,
|
|
data=data,
|
|
headers=headers,
|
|
method=method,
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
payload = json.loads(response.read())
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
payload = json.loads(exc.read())
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
payload = {}
|
|
detail = payload.get("detail", payload) if isinstance(payload, dict) else {}
|
|
if isinstance(detail, dict):
|
|
message = str(detail.get("message") or f"State Hub returned HTTP {exc.code}")
|
|
code = str(detail.get("code") or "state_hub_http_error")
|
|
details = detail.get("details")
|
|
else:
|
|
message = str(detail or f"State Hub returned HTTP {exc.code}")
|
|
code = "state_hub_http_error"
|
|
details = None
|
|
raise RenameCLIError(
|
|
_redact_text(message),
|
|
code=code,
|
|
status_code=exc.code,
|
|
details=_public(details),
|
|
) from exc
|
|
except (urllib.error.URLError, TimeoutError) as exc:
|
|
raise RenameCLIError(
|
|
"State Hub is unavailable",
|
|
code="state_hub_unavailable",
|
|
details={"reason": _redact_text(str(exc.reason if hasattr(exc, "reason") else exc))},
|
|
) from exc
|
|
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
raise RenameCLIError(
|
|
"State Hub returned an invalid JSON response",
|
|
code="invalid_state_hub_response",
|
|
) from exc
|
|
if not isinstance(payload, expected):
|
|
expected_name = (
|
|
", ".join(item.__name__ for item in expected)
|
|
if isinstance(expected, tuple)
|
|
else expected.__name__
|
|
)
|
|
raise RenameCLIError(
|
|
f"State Hub returned JSON with the wrong shape; expected {expected_name}",
|
|
code="invalid_state_hub_response",
|
|
)
|
|
return payload
|
|
|
|
|
|
def _load_json(path: str, *, expected: type | tuple[type, ...]) -> Any:
|
|
try:
|
|
if path == "-":
|
|
value = json.load(sys.stdin)
|
|
else:
|
|
target = Path(path)
|
|
if target.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
raise RenameCLIError(
|
|
f"Refusing non-private input file {target}; require mode 0600",
|
|
code="unsafe_input_permissions",
|
|
)
|
|
value = json.loads(target.read_text(encoding="utf-8"))
|
|
except RenameCLIError:
|
|
raise
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise RenameCLIError(
|
|
f"Could not read JSON input {path}",
|
|
code="invalid_json_input",
|
|
details={"reason": _redact_text(str(exc))},
|
|
) from exc
|
|
if not isinstance(value, expected):
|
|
names = (
|
|
", ".join(item.__name__ for item in expected)
|
|
if isinstance(expected, tuple)
|
|
else expected.__name__
|
|
)
|
|
raise RenameCLIError(
|
|
f"JSON input {path} must contain {names}", code="invalid_json_input"
|
|
)
|
|
return value
|
|
|
|
|
|
def _write_private_json(path: str, payload: dict[str, Any]) -> None:
|
|
target = Path(path)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
except FileExistsError as exc:
|
|
raise RenameCLIError(
|
|
f"Refusing to overwrite existing preflight file {target}",
|
|
code="output_exists",
|
|
) from exc
|
|
try:
|
|
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
handle.write("\n")
|
|
except Exception:
|
|
target.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
def _confirmation(repo_id: str, old_slug: str, new_slug: str) -> str:
|
|
return f"rename:{repo_id}:{old_slug}:{new_slug}"
|
|
|
|
|
|
def _rollback_confirmation(operation_id: str) -> str:
|
|
return f"rollback:{operation_id}"
|
|
|
|
|
|
def _validated_uuid(value: str, *, field: str = "operation ID") -> str:
|
|
try:
|
|
return str(uuid.UUID(value))
|
|
except (AttributeError, TypeError, ValueError) as exc:
|
|
raise RenameCLIError(
|
|
f"{field} must be a UUID",
|
|
code="invalid_operation_id",
|
|
) from exc
|
|
|
|
|
|
def _command(*parts: str | None) -> list[str]:
|
|
return [part for part in parts if part is not None]
|
|
|
|
|
|
def _next_for_operation(operation: dict[str, Any]) -> dict[str, Any]:
|
|
operation_id = str(operation["id"])
|
|
repo_id = str(operation["repo_id"])
|
|
phase = str(operation["phase"])
|
|
confirmation = _confirmation(
|
|
repo_id, str(operation["old_slug"]), str(operation["new_slug"])
|
|
)
|
|
if phase in NEXT_PHASE:
|
|
target = NEXT_PHASE[phase]
|
|
command = _command(
|
|
"statehub",
|
|
"repo",
|
|
"rename",
|
|
"apply",
|
|
operation_id,
|
|
"--phase",
|
|
target,
|
|
"--confirm",
|
|
confirmation,
|
|
)
|
|
if target in {"source-synced", "consumers-verified"}:
|
|
command.extend(["--evidence-file", "<evidence.json>"])
|
|
if target == "consumers-verified":
|
|
command.extend(["--checks-file", "<checks.json>"])
|
|
return {
|
|
"action": "apply-phase",
|
|
"phase": target,
|
|
"expected_phase": phase,
|
|
"requires_confirmation": True,
|
|
"requires_evidence": target in {"source-synced", "consumers-verified"},
|
|
"requires_checks": target == "consumers-verified",
|
|
"command": command,
|
|
}
|
|
if phase == "rollback-preflight":
|
|
return {
|
|
"action": "execute-rollback",
|
|
"requires_confirmation": True,
|
|
"command": [
|
|
"statehub",
|
|
"repo",
|
|
"rename",
|
|
"rollback",
|
|
operation_id,
|
|
"--confirm",
|
|
_rollback_confirmation(operation_id),
|
|
"--execute",
|
|
],
|
|
}
|
|
if phase == "completed":
|
|
return {"action": "none", "reason": "rename completed", "command": None}
|
|
if phase == "rolled-back":
|
|
return {"action": "none", "reason": "rename rolled back", "command": None}
|
|
return {
|
|
"action": "inspect",
|
|
"reason": f"unsupported or unknown phase {phase!r}",
|
|
"command": ["statehub", "repo", "rename", "status", operation_id],
|
|
}
|
|
|
|
|
|
def _state_for_operation(operation: dict[str, Any]) -> str:
|
|
if operation.get("phase") == "rolled-back":
|
|
return "rolled-back"
|
|
if operation.get("error_code"):
|
|
return "failed"
|
|
if operation.get("phase") == "completed":
|
|
return "achieved"
|
|
return "planned"
|
|
|
|
|
|
def _envelope(
|
|
command: str,
|
|
*,
|
|
state: str,
|
|
ok: bool,
|
|
result: dict[str, Any] | None = None,
|
|
operation_id: str | None = None,
|
|
repo_id: str | None = None,
|
|
phase: str | None = None,
|
|
next_safe_action: dict[str, Any] | None = None,
|
|
error: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"schema_version": CLI_SCHEMA_VERSION,
|
|
"command": command,
|
|
"ok": ok,
|
|
"state": state,
|
|
"operation_id": operation_id,
|
|
"repo_id": repo_id,
|
|
"phase": phase,
|
|
"next_safe_action": next_safe_action,
|
|
"result": result,
|
|
"error": error,
|
|
}
|
|
|
|
|
|
def _emit(payload: dict[str, Any], *, as_json: bool) -> None:
|
|
public = _public(payload)
|
|
if as_json:
|
|
print(json.dumps(public, indent=2, sort_keys=True))
|
|
return
|
|
state = str(public["state"]).upper()
|
|
stream = sys.stderr if not public["ok"] else sys.stdout
|
|
print(f"{state}: repository rename {public['command']}", file=stream)
|
|
if public.get("operation_id"):
|
|
print(f" operation: {public['operation_id']}", file=stream)
|
|
if public.get("repo_id"):
|
|
print(f" repository: {public['repo_id']}", file=stream)
|
|
if public.get("phase"):
|
|
print(f" phase: {public['phase']}", file=stream)
|
|
result = public.get("result") or {}
|
|
if public["command"] == "preflight":
|
|
print(f" safe to apply: {bool(result.get('safe_to_apply'))}", file=stream)
|
|
print(f" blockers: {len(result.get('blockers') or [])}", file=stream)
|
|
if result.get("preflight_file"):
|
|
print(f" private preflight: {result['preflight_file']}", file=stream)
|
|
elif public["command"] == "generate-workplan" and result.get("output"):
|
|
print(f" workplan: {result['output']}", file=stream)
|
|
print(f" workplan id: {result.get('workplan_id')}", file=stream)
|
|
error = public.get("error")
|
|
if error:
|
|
print(f" error: {error.get('message')}", file=stream)
|
|
action = public.get("next_safe_action") or {}
|
|
if action.get("action"):
|
|
print(f" next safe action: {action['action']}", file=stream)
|
|
if action.get("command"):
|
|
print(f" command: {shlex.join(action['command'])}", file=stream)
|
|
|
|
|
|
def _run(args: argparse.Namespace, action: Callable[[], dict[str, Any]]) -> None:
|
|
try:
|
|
payload = action()
|
|
except RenameCLIError as exc:
|
|
payload = _envelope(
|
|
args.rename_command,
|
|
state="failed",
|
|
ok=False,
|
|
operation_id=getattr(args, "operation_id", None),
|
|
error={
|
|
"code": exc.code,
|
|
"message": str(exc),
|
|
"status_code": exc.status_code,
|
|
"details": exc.details,
|
|
},
|
|
next_safe_action={"action": "inspect-input-or-status", "command": None},
|
|
)
|
|
_emit(payload, as_json=args.as_json)
|
|
raise SystemExit(1) from exc
|
|
_emit(payload, as_json=args.as_json)
|
|
if not payload["ok"]:
|
|
raise SystemExit(2)
|
|
|
|
|
|
def _resolve_repo(args: argparse.Namespace, slug: str) -> dict[str, Any]:
|
|
quoted = urllib.parse.quote(slug, safe="")
|
|
repo = _api_request(args.api_base, "GET", f"/repos/{quoted}")
|
|
if repo.get("slug_status") == "alias":
|
|
raise RenameCLIError(
|
|
f"{slug!r} is a prior alias; current canonical slug is {repo.get('canonical_slug')!r}",
|
|
code="old_slug_is_alias",
|
|
)
|
|
return repo
|
|
|
|
|
|
def cmd_preflight(args: argparse.Namespace) -> None:
|
|
def action() -> dict[str, Any]:
|
|
operation_id = (
|
|
_validated_uuid(args.operation_id)
|
|
if args.operation_id
|
|
else str(uuid.uuid4())
|
|
)
|
|
if args.output and Path(args.output).exists():
|
|
raise RenameCLIError(
|
|
f"Refusing to overwrite existing preflight file {args.output}",
|
|
code="output_exists",
|
|
)
|
|
repo = _resolve_repo(args, args.old_slug)
|
|
edge_writes: list[dict[str, Any]] = []
|
|
if args.queued_edge_writes:
|
|
edge_writes = _load_json(args.queued_edge_writes, expected=list)
|
|
if not all(isinstance(item, dict) for item in edge_writes):
|
|
raise RenameCLIError(
|
|
"Queued edge-write input must be a list of objects",
|
|
code="invalid_json_input",
|
|
)
|
|
report = _api_request(
|
|
args.api_base,
|
|
"POST",
|
|
f"/repos/{repo['id']}/rename/preflight",
|
|
{"new_slug": args.new_slug, "queued_edge_writes": edge_writes},
|
|
)
|
|
confirmation = _confirmation(str(repo["id"]), args.old_slug, args.new_slug)
|
|
private = _envelope(
|
|
"preflight",
|
|
state="planned",
|
|
ok=bool(report.get("safe_to_apply")),
|
|
result=report,
|
|
operation_id=operation_id,
|
|
repo_id=str(repo["id"]),
|
|
phase=None,
|
|
)
|
|
if args.output:
|
|
_write_private_json(args.output, private)
|
|
if report.get("safe_to_apply") and args.output:
|
|
next_action = {
|
|
"action": "start-operation",
|
|
"requires_confirmation": True,
|
|
"command": [
|
|
"statehub", "repo", "rename", "start",
|
|
args.old_slug, args.new_slug,
|
|
"--operation-id", operation_id,
|
|
"--preflight-file", args.output,
|
|
"--actor", "<actor>",
|
|
"--confirm", confirmation,
|
|
],
|
|
}
|
|
elif report.get("safe_to_apply"):
|
|
next_action = {
|
|
"action": "save-private-preflight",
|
|
"requires_confirmation": False,
|
|
"command": [
|
|
"statehub", "repo", "rename", "preflight",
|
|
args.old_slug, args.new_slug,
|
|
"--operation-id", operation_id,
|
|
"--output", "<preflight-report.json>",
|
|
],
|
|
}
|
|
else:
|
|
next_action = {
|
|
"action": "resolve-blockers",
|
|
"requires_confirmation": False,
|
|
"command": None,
|
|
}
|
|
public_report = dict(report)
|
|
public_report["preflight_file"] = args.output
|
|
return _envelope(
|
|
"preflight",
|
|
state="planned" if report.get("safe_to_apply") else "failed",
|
|
ok=bool(report.get("safe_to_apply")),
|
|
result=public_report,
|
|
operation_id=operation_id,
|
|
repo_id=str(repo["id"]),
|
|
next_safe_action=next_action,
|
|
)
|
|
|
|
_run(args, action)
|
|
|
|
|
|
def cmd_generate_workplan(args: argparse.Namespace) -> None:
|
|
def action() -> dict[str, Any]:
|
|
if args.output and Path(args.output).expanduser().exists():
|
|
raise RenameCLIError(
|
|
f"Refusing to overwrite existing workplan {args.output}",
|
|
code="output_exists",
|
|
)
|
|
repo = _resolve_repo(args, args.old_slug)
|
|
workplans = _api_request(
|
|
args.api_base,
|
|
"GET",
|
|
f"/workplans/?repo_id={urllib.parse.quote(str(repo['id']), safe='')}",
|
|
expected=list,
|
|
)
|
|
if args.preflight_file:
|
|
wrapper = _load_json(args.preflight_file, expected=dict)
|
|
if (
|
|
wrapper.get("schema_version") != CLI_SCHEMA_VERSION
|
|
or wrapper.get("command") != "preflight"
|
|
or str(wrapper.get("repo_id")) != str(repo["id"])
|
|
):
|
|
raise RenameCLIError(
|
|
"Preflight file is not for this registered repository",
|
|
code="invalid_preflight_file",
|
|
)
|
|
report = wrapper.get("result") or {}
|
|
else:
|
|
report = _api_request(
|
|
args.api_base,
|
|
"POST",
|
|
f"/repos/{repo['id']}/rename/preflight",
|
|
{"new_slug": args.new_slug, "queued_edge_writes": []},
|
|
)
|
|
if (
|
|
report.get("old_slug") != args.old_slug
|
|
or report.get("new_slug") != args.new_slug
|
|
):
|
|
raise RenameCLIError(
|
|
"Preflight snapshot does not address the requested rename",
|
|
code="invalid_preflight_file",
|
|
)
|
|
try:
|
|
snapshot = build_generation_snapshot(
|
|
repo=repo,
|
|
preflight=report,
|
|
workplans=workplans,
|
|
repo_path_override=args.repo_path,
|
|
)
|
|
content = render_workplan(snapshot, owner=args.owner)
|
|
output = (
|
|
Path(args.output).expanduser().resolve()
|
|
if args.output
|
|
else default_output_path(snapshot)
|
|
)
|
|
write_workplan_exclusive(output, content)
|
|
except WorkplanGenerationError as exc:
|
|
raise RenameCLIError(str(exc), code=exc.code) from exc
|
|
return _envelope(
|
|
"generate-workplan",
|
|
state="achieved",
|
|
ok=True,
|
|
result={
|
|
"output": str(output),
|
|
"workplan_id": snapshot.workplan_id,
|
|
"workplan_prefix": snapshot.prefix,
|
|
"content_checksum": content_checksum(content),
|
|
"preflight_report_checksum": report.get("report_checksum"),
|
|
"safe_to_apply_at_capture": bool(report.get("safe_to_apply")),
|
|
"path_available": snapshot.path_available,
|
|
"state_hub_uuid_fields_written": [],
|
|
},
|
|
repo_id=str(repo["id"]),
|
|
next_safe_action={
|
|
"action": "review-generated-workplan",
|
|
"requires_confirmation": False,
|
|
"command": None,
|
|
},
|
|
)
|
|
|
|
_run(args, action)
|
|
|
|
|
|
def cmd_start(args: argparse.Namespace) -> None:
|
|
def action() -> dict[str, Any]:
|
|
operation_id = _validated_uuid(args.operation_id)
|
|
try:
|
|
existing = _api_request(
|
|
args.api_base,
|
|
"GET",
|
|
f"/repository-renames/operations/{operation_id}",
|
|
)
|
|
except RenameCLIError as exc:
|
|
if exc.status_code != 404:
|
|
raise
|
|
else:
|
|
expected_confirmation = _confirmation(
|
|
str(existing["repo_id"]),
|
|
str(existing["old_slug"]),
|
|
str(existing["new_slug"]),
|
|
)
|
|
if (
|
|
existing.get("old_slug") != args.old_slug
|
|
or existing.get("new_slug") != args.new_slug
|
|
or existing.get("actor") != args.actor
|
|
or args.confirm != expected_confirmation
|
|
):
|
|
raise RenameCLIError(
|
|
"Operation ID is already bound to another repository rename",
|
|
code="operation_id_conflict",
|
|
)
|
|
existing = {**existing, "no_op": True}
|
|
return _operation_envelope("start", existing)
|
|
|
|
wrapper = _load_json(args.preflight_file, expected=dict)
|
|
if (
|
|
wrapper.get("schema_version") != CLI_SCHEMA_VERSION
|
|
or wrapper.get("command") != "preflight"
|
|
):
|
|
raise RenameCLIError(
|
|
"Preflight file is not a repository-rename CLI preflight",
|
|
code="invalid_preflight_file",
|
|
)
|
|
if str(wrapper.get("operation_id")) != operation_id:
|
|
raise RenameCLIError(
|
|
"Preflight operation ID does not match --operation-id",
|
|
code="invalid_preflight_file",
|
|
)
|
|
report = wrapper.get("result") or {}
|
|
repo = _resolve_repo(args, args.old_slug)
|
|
if (
|
|
str(wrapper.get("repo_id")) != str(repo["id"])
|
|
or report.get("old_slug") != args.old_slug
|
|
or report.get("new_slug") != args.new_slug
|
|
or not report.get("safe_to_apply")
|
|
or not report.get("preflight_token")
|
|
):
|
|
raise RenameCLIError(
|
|
"Preflight file does not authorize this repository rename",
|
|
code="invalid_preflight_file",
|
|
)
|
|
expected_confirmation = _confirmation(
|
|
str(repo["id"]), args.old_slug, args.new_slug
|
|
)
|
|
if args.confirm != expected_confirmation:
|
|
raise RenameCLIError(
|
|
"Explicit repository rename confirmation is incorrect",
|
|
code="confirmation_mismatch",
|
|
)
|
|
operation = _api_request(
|
|
args.api_base,
|
|
"POST",
|
|
f"/repos/{repo['id']}/rename/operations",
|
|
{
|
|
"operation_id": operation_id,
|
|
"new_slug": args.new_slug,
|
|
"preflight_token": report["preflight_token"],
|
|
"confirmation": args.confirm,
|
|
"actor": args.actor,
|
|
"queued_edge_writes": report.get("queued_edge_writes") or [],
|
|
},
|
|
)
|
|
return _operation_envelope("start", operation)
|
|
|
|
_run(args, action)
|
|
|
|
|
|
def _load_operation(args: argparse.Namespace) -> dict[str, Any]:
|
|
operation_id = _validated_uuid(args.operation_id)
|
|
return _api_request(
|
|
args.api_base,
|
|
"GET",
|
|
f"/repository-renames/operations/{operation_id}",
|
|
)
|
|
|
|
|
|
def _operation_envelope(command: str, operation: dict[str, Any]) -> dict[str, Any]:
|
|
return _envelope(
|
|
command,
|
|
state=_state_for_operation(operation),
|
|
ok=not bool(operation.get("error_code")),
|
|
result=operation,
|
|
operation_id=str(operation["id"]),
|
|
repo_id=str(operation["repo_id"]),
|
|
phase=str(operation["phase"]),
|
|
next_safe_action=_next_for_operation(operation),
|
|
)
|
|
|
|
|
|
def cmd_status(args: argparse.Namespace) -> None:
|
|
_run(args, lambda: _operation_envelope("status", _load_operation(args)))
|
|
|
|
|
|
def cmd_apply(args: argparse.Namespace) -> None:
|
|
def action() -> dict[str, Any]:
|
|
operation = _load_operation(args)
|
|
expected_confirmation = _confirmation(
|
|
str(operation["repo_id"]),
|
|
str(operation["old_slug"]),
|
|
str(operation["new_slug"]),
|
|
)
|
|
if args.confirm != expected_confirmation:
|
|
raise RenameCLIError(
|
|
"Explicit repository rename confirmation is incorrect",
|
|
code="confirmation_mismatch",
|
|
)
|
|
evidence = (
|
|
_load_json(args.evidence_file, expected=dict)
|
|
if args.evidence_file
|
|
else {}
|
|
)
|
|
checks = (
|
|
_load_json(args.checks_file, expected=dict)
|
|
if args.checks_file
|
|
else {}
|
|
)
|
|
if any(not isinstance(value, bool) for value in checks.values()):
|
|
raise RenameCLIError(
|
|
"Checks file values must all be booleans",
|
|
code="invalid_json_input",
|
|
)
|
|
updated = _api_request(
|
|
args.api_base,
|
|
"POST",
|
|
f"/repos/{operation['repo_id']}/rename/operations/{operation['id']}/phases/{args.phase}",
|
|
{
|
|
"expected_phase": args.expected_phase or operation["phase"],
|
|
"confirmation": args.confirm,
|
|
"checks": checks,
|
|
"evidence": evidence,
|
|
},
|
|
)
|
|
return _operation_envelope("apply", updated)
|
|
|
|
_run(args, action)
|
|
|
|
|
|
def cmd_verify(args: argparse.Namespace) -> None:
|
|
def action() -> dict[str, Any]:
|
|
operation = _load_operation(args)
|
|
verification = _api_request(
|
|
args.api_base,
|
|
"GET",
|
|
f"/repos/{operation['repo_id']}/rename/operations/{operation['id']}/verify",
|
|
)
|
|
if verification.get("ok"):
|
|
next_action = _next_for_operation(operation)
|
|
state = "achieved"
|
|
else:
|
|
next_action = {
|
|
"action": "resolve-verification-failures",
|
|
"command": [
|
|
"statehub", "repo", "rename", "status", str(operation["id"])
|
|
],
|
|
}
|
|
state = "failed"
|
|
return _envelope(
|
|
"verify",
|
|
state=state,
|
|
ok=bool(verification.get("ok")),
|
|
result=verification,
|
|
operation_id=str(operation["id"]),
|
|
repo_id=str(operation["repo_id"]),
|
|
phase=str(operation["phase"]),
|
|
next_safe_action=next_action,
|
|
)
|
|
|
|
_run(args, action)
|
|
|
|
|
|
def cmd_rollback(args: argparse.Namespace) -> None:
|
|
def action() -> dict[str, Any]:
|
|
operation = _load_operation(args)
|
|
operation_id = str(operation["id"])
|
|
expected_confirmation = _rollback_confirmation(operation_id)
|
|
if args.confirm != expected_confirmation:
|
|
raise RenameCLIError(
|
|
"Explicit rollback confirmation is incorrect",
|
|
code="confirmation_mismatch",
|
|
)
|
|
if operation["phase"] == "rolled-back":
|
|
return _operation_envelope("rollback", operation)
|
|
if operation["phase"] != "rollback-preflight":
|
|
preflight = _api_request(
|
|
args.api_base,
|
|
"POST",
|
|
f"/repos/{operation['repo_id']}/rename/operations/{operation_id}/rollback-preflight",
|
|
{
|
|
"expected_phase": args.expected_phase or operation["phase"],
|
|
"confirmation": args.confirm,
|
|
},
|
|
)
|
|
operation = preflight["operation"]
|
|
if not preflight.get("safe_to_rollback"):
|
|
raise RenameCLIError(
|
|
"Repository rename rollback is unsafe",
|
|
code="rollback_unsafe",
|
|
details=preflight,
|
|
)
|
|
if not args.execute:
|
|
return _operation_envelope("rollback", operation)
|
|
rolled_back = _api_request(
|
|
args.api_base,
|
|
"POST",
|
|
f"/repos/{operation['repo_id']}/rename/operations/{operation_id}/rollback",
|
|
{"expected_phase": "rollback-preflight", "confirmation": args.confirm},
|
|
)
|
|
return _operation_envelope("rollback", rolled_back)
|
|
|
|
_run(args, action)
|
|
|
|
|
|
def _common(parser: argparse.ArgumentParser) -> None:
|
|
parser.add_argument(
|
|
"--api-base", default=DEFAULT_API_BASE, help="State Hub API base URL"
|
|
)
|
|
parser.add_argument(
|
|
"--json",
|
|
action="store_true",
|
|
dest="as_json",
|
|
help="Emit the stable orchestration JSON contract",
|
|
)
|
|
|
|
|
|
def configure_repo_commands(subparsers: argparse._SubParsersAction) -> None:
|
|
repo = subparsers.add_parser("repo", help="Repository lifecycle commands")
|
|
repo_sub = repo.add_subparsers(dest="repo_command", required=True)
|
|
rename = repo_sub.add_parser(
|
|
"rename", help="Governed repository rename lifecycle"
|
|
)
|
|
rename_sub = rename.add_subparsers(dest="rename_command", required=True)
|
|
|
|
preflight = rename_sub.add_parser(
|
|
"preflight",
|
|
help="Inspect rename safety without mutating State Hub or Forgejo",
|
|
)
|
|
preflight.add_argument("old_slug")
|
|
preflight.add_argument("new_slug")
|
|
preflight.add_argument(
|
|
"--operation-id",
|
|
default=None,
|
|
help="Client-owned UUID; generated when omitted",
|
|
)
|
|
preflight.add_argument(
|
|
"--queued-edge-writes",
|
|
default=None,
|
|
help="Mode-0600 JSON list of queued-write evidence",
|
|
)
|
|
preflight.add_argument(
|
|
"--output",
|
|
default=None,
|
|
help="Write the token-bearing preflight envelope to a new mode-0600 file",
|
|
)
|
|
_common(preflight)
|
|
preflight.set_defaults(func=cmd_preflight)
|
|
|
|
generate = rename_sub.add_parser(
|
|
"generate-workplan",
|
|
help="Generate a repository-native rename adoption workplan",
|
|
)
|
|
generate.add_argument("old_slug")
|
|
generate.add_argument("new_slug")
|
|
generate.add_argument(
|
|
"--preflight-file",
|
|
default=None,
|
|
help="Use a private mode-0600 preflight envelope instead of refreshing",
|
|
)
|
|
generate.add_argument(
|
|
"--repo-path",
|
|
default=None,
|
|
help="Visible target checkout used for workplan convention discovery",
|
|
)
|
|
generate.add_argument(
|
|
"--output",
|
|
default=None,
|
|
help="Exclusive output path; defaults to the target workplans directory",
|
|
)
|
|
generate.add_argument("--owner", default="codex")
|
|
_common(generate)
|
|
generate.set_defaults(func=cmd_generate_workplan)
|
|
|
|
start = rename_sub.add_parser(
|
|
"start",
|
|
help="Create an idempotent operation journal from a private preflight file",
|
|
)
|
|
start.add_argument("old_slug")
|
|
start.add_argument("new_slug")
|
|
start.add_argument("--operation-id", required=True)
|
|
start.add_argument(
|
|
"--preflight-file",
|
|
required=True,
|
|
help="Mode-0600 preflight file, or - for stdin",
|
|
)
|
|
start.add_argument("--actor", required=True)
|
|
start.add_argument("--confirm", required=True, metavar="CONFIRMATION")
|
|
_common(start)
|
|
start.set_defaults(func=cmd_start)
|
|
|
|
apply = rename_sub.add_parser(
|
|
"apply", help="Apply exactly one compare-and-set lifecycle phase"
|
|
)
|
|
apply.add_argument("operation_id")
|
|
apply.add_argument("--phase", required=True, choices=FORWARD_PHASES)
|
|
apply.add_argument("--expected-phase", default=None)
|
|
apply.add_argument("--confirm", required=True, metavar="CONFIRMATION")
|
|
apply.add_argument(
|
|
"--evidence-file",
|
|
default=None,
|
|
help="Mode-0600 JSON object, or - for stdin",
|
|
)
|
|
apply.add_argument(
|
|
"--checks-file", default=None, help="Mode-0600 JSON boolean map"
|
|
)
|
|
_common(apply)
|
|
apply.set_defaults(func=cmd_apply)
|
|
|
|
status_parser = rename_sub.add_parser(
|
|
"status", help="Read the durable operation journal"
|
|
)
|
|
status_parser.add_argument("operation_id")
|
|
_common(status_parser)
|
|
status_parser.set_defaults(func=cmd_status)
|
|
|
|
verify = rename_sub.add_parser(
|
|
"verify", help="Verify Forge identity and State Hub continuity"
|
|
)
|
|
verify.add_argument("operation_id")
|
|
_common(verify)
|
|
verify.set_defaults(func=cmd_verify)
|
|
|
|
rollback = rename_sub.add_parser(
|
|
"rollback",
|
|
help="Preflight rollback; --execute is required to perform it",
|
|
)
|
|
rollback.add_argument("operation_id")
|
|
rollback.add_argument("--expected-phase", default=None)
|
|
rollback.add_argument("--confirm", required=True, metavar="CONFIRMATION")
|
|
rollback.add_argument(
|
|
"--execute",
|
|
action="store_true",
|
|
help="Execute after rollback preflight succeeds",
|
|
)
|
|
_common(rollback)
|
|
rollback.set_defaults(func=cmd_rollback)
|
|
|
|
|
|
__all__ = [
|
|
"CLI_SCHEMA_VERSION",
|
|
"RenameCLIError",
|
|
"configure_repo_commands",
|
|
]
|