Files
ss-tools/backend/src/scripts/clean_release_tui.py
2026-05-26 09:30:41 +03:00

785 lines
32 KiB
Python

# #region CleanReleaseTuiScript [C:3] [TYPE Module] [SEMANTICS clean-release, validate, compliance, release, tui-facade-adapter]
# @BRIEF Interactive terminal interface for Enterprise Clean Release compliance validation.
# @LAYER UI
# @RELATION DEPENDS_ON -> [ComplianceExecutionService]
# @RELATION DEPENDS_ON -> [CleanReleaseRepository]
# @INVARIANT TUI refuses startup in non-TTY environments; headless flow is CLI/API only.
# @DATA_CONTRACT CLIArgs -> TUIExitCode
import contextlib
import curses
from datetime import UTC, datetime
import json
import os
import subprocess
import sys
import threading
from types import SimpleNamespace
from typing import Any
# Standardize sys.path for direct execution from project root or scripts dir.
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
BACKEND_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", ".."))
if BACKEND_ROOT not in sys.path:
sys.path.insert(0, BACKEND_ROOT)
from src.core.cot_logger import seed_trace_id
from src.core.logger import logger
from src.models.clean_release import (
CandidateArtifact,
CheckFinalStatus,
CheckStageName,
CheckStageStatus,
CleanProfilePolicy,
ComplianceViolation,
ProfileType,
RegistryStatus,
ReleaseCandidate,
ReleaseCandidateStatus,
ResourceSourceEntry,
ResourceSourceRegistry,
)
from src.services.clean_release.approval_service import approve_candidate
from src.services.clean_release.artifact_catalog_loader import load_bootstrap_artifacts
from src.services.clean_release.compliance_execution_service import (
ComplianceExecutionService,
)
from src.services.clean_release.enums import CandidateStatus
from src.services.clean_release.manifest_service import build_manifest_snapshot
from src.services.clean_release.publication_service import publish_candidate
from src.services.clean_release.repository import CleanReleaseRepository
# #region TuiFacadeAdapter [TYPE Class]
# @BRIEF Thin TUI adapter that routes business mutations through application services.
# @PRE repository contains candidate and trusted policy/registry snapshots for execution.
# @POST Business actions return service results/errors without direct TUI-owned mutations.
class TuiFacadeAdapter:
def __init__(self, repository: CleanReleaseRepository):
self.repository = repository
def _build_config_manager(self):
policy = self.repository.get_active_policy()
if policy is None:
raise ValueError("Active policy not found")
clean_release = SimpleNamespace(
active_policy_id=policy.id,
active_registry_id=policy.registry_snapshot_id,
)
settings = SimpleNamespace(clean_release=clean_release)
config = SimpleNamespace(settings=settings)
return SimpleNamespace(get_config=lambda: config)
def run_compliance(self, *, candidate_id: str, actor: str):
manifests = self.repository.get_manifests_by_candidate(candidate_id)
if not manifests:
raise ValueError("Manifest required before compliance run")
latest_manifest = sorted(
manifests, key=lambda item: item.manifest_version, reverse=True
)[0]
service = ComplianceExecutionService(
repository=self.repository,
config_manager=self._build_config_manager(),
)
return service.execute_run(
candidate_id=candidate_id,
requested_by=actor,
manifest_id=latest_manifest.id,
)
def approve_latest(self, *, candidate_id: str, actor: str):
reports = [
item
for item in self.repository.reports.values()
if item.candidate_id == candidate_id
]
if not reports:
raise ValueError("No compliance report available for approval")
report = sorted(reports, key=lambda item: item.generated_at, reverse=True)[0]
return approve_candidate(
repository=self.repository,
candidate_id=candidate_id,
report_id=report.id,
decided_by=actor,
comment="Approved from TUI",
)
def publish_latest(self, *, candidate_id: str, actor: str):
reports = [
item
for item in self.repository.reports.values()
if item.candidate_id == candidate_id
]
if not reports:
raise ValueError("No compliance report available for publication")
report = sorted(reports, key=lambda item: item.generated_at, reverse=True)[0]
return publish_candidate(
repository=self.repository,
candidate_id=candidate_id,
report_id=report.id,
published_by=actor,
target_channel="stable",
publication_ref=None,
)
def build_manifest(self, *, candidate_id: str, actor: str):
return build_manifest_snapshot(
repository=self.repository,
candidate_id=candidate_id,
created_by=actor,
)
def get_overview(self, *, candidate_id: str) -> dict[str, Any]:
candidate = self.repository.get_candidate(candidate_id)
manifests = self.repository.get_manifests_by_candidate(candidate_id)
latest_manifest = (
sorted(manifests, key=lambda item: item.manifest_version, reverse=True)[0]
if manifests
else None
)
runs = [
item
for item in self.repository.check_runs.values()
if item.candidate_id == candidate_id
]
latest_run = (
sorted(runs, key=lambda item: item.requested_at, reverse=True)[0]
if runs
else None
)
latest_report = next(
(
item
for item in self.repository.reports.values()
if latest_run and item.run_id == latest_run.id
),
None,
)
approvals = getattr(self.repository, "approval_decisions", [])
latest_approval = (
sorted(
[item for item in approvals if item.candidate_id == candidate_id],
key=lambda item: item.decided_at,
reverse=True,
)[0]
if any(item.candidate_id == candidate_id for item in approvals)
else None
)
publications = getattr(self.repository, "publication_records", [])
latest_publication = (
sorted(
[item for item in publications if item.candidate_id == candidate_id],
key=lambda item: item.published_at,
reverse=True,
)[0]
if any(item.candidate_id == candidate_id for item in publications)
else None
)
policy = self.repository.get_active_policy()
registry = (
self.repository.get_registry(policy.internal_source_registry_ref)
if policy
else None
)
return {
"candidate": candidate,
"manifest": latest_manifest,
"run": latest_run,
"report": latest_report,
"approval": latest_approval,
"publication": latest_publication,
"policy": policy,
"registry": registry,
}
# #endregion TuiFacadeAdapter
# #region CleanReleaseTUI [TYPE Class]
# @BRIEF Curses-based application for compliance monitoring.
# @UX_STATE READY -> Waiting for operator to start checks (F5).
# @UX_STATE RUNNING -> Executing compliance stages with progress feedback.
# @UX_STATE COMPLIANT -> Release candidate passed all checks.
# @UX_STATE BLOCKED -> Violations detected, release forbidden.
# @UX_FEEDBACK Red alerts for BLOCKED status, Green for COMPLIANT.
class CleanReleaseTUI:
def __init__(self, stdscr: curses.window):
self.stdscr = stdscr
self.mode = os.getenv("CLEAN_TUI_MODE", "demo").strip().lower()
self.repo = self._build_repository(self.mode)
self.facade = TuiFacadeAdapter(self.repo)
self.candidate_id = self._resolve_candidate_id()
self.status: Any = "READY"
self.checks_progress: list[dict[str, Any]] = []
self.violations_list: list[ComplianceViolation] = []
self.report_id: str | None = None
self.last_error: str | None = None
self.overview: dict[str, Any] = {}
self.refresh_overview()
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) # Header/Footer
curses.init_pair(2, curses.COLOR_GREEN, -1) # PASS
curses.init_pair(3, curses.COLOR_RED, -1) # FAIL/BLOCKED
curses.init_pair(4, curses.COLOR_YELLOW, -1) # RUNNING
curses.init_pair(5, curses.COLOR_CYAN, -1) # Text
def _build_repository(self, mode: str) -> CleanReleaseRepository:
repo = CleanReleaseRepository()
if mode == "demo":
self._bootstrap_demo_repository(repo)
else:
self._bootstrap_real_repository(repo)
return repo
def _bootstrap_demo_repository(self, repository: CleanReleaseRepository) -> None:
now = datetime.now(UTC)
policy = CleanProfilePolicy(
policy_id="POL-ENT-CLEAN",
policy_version="1",
profile=ProfileType.ENTERPRISE_CLEAN,
active=True,
internal_source_registry_ref="REG-1",
prohibited_artifact_categories=["test-data"],
effective_from=now,
)
policy.immutable = True
repository.save_policy(policy)
registry = ResourceSourceRegistry(
registry_id="REG-1",
name="Default Internal Registry",
entries=[
ResourceSourceEntry(
source_id="S1",
host="internal-repo.company.com",
protocol="https",
purpose="artifactory",
)
],
updated_at=now,
updated_by="system",
)
registry.immutable = True
registry.allowed_hosts = ["internal-repo.company.com"]
registry.allowed_schemes = ["https"]
registry.allowed_source_types = ["artifactory"]
repository.save_registry(registry)
candidate = ReleaseCandidate(
id="2026.03.03-rc1",
version="1.0.0",
source_snapshot_ref="v1.0.0-rc1",
created_at=now,
created_by="system",
status=CandidateStatus.DRAFT.value,
)
candidate.transition_to(CandidateStatus.PREPARED)
repository.save_candidate(candidate)
repository.save_artifact(
CandidateArtifact(
id="demo-art-1",
candidate_id=candidate.id,
path="src/main.py",
sha256="sha256-demo-core",
size=128,
detected_category="core",
)
)
repository.save_artifact(
CandidateArtifact(
id="demo-art-2",
candidate_id=candidate.id,
path="test/data.csv",
sha256="sha256-demo-test",
size=64,
detected_category="test-data",
)
)
manifest = build_manifest_snapshot(
repository=repository,
candidate_id=candidate.id,
created_by="system",
policy_id="POL-ENT-CLEAN",
)
summary = dict(manifest.content_json.get("summary", {}))
summary["prohibited_detected_count"] = 1
manifest.content_json["summary"] = summary
def _bootstrap_real_repository(self, repository: CleanReleaseRepository) -> None:
bootstrap_path = os.getenv("CLEAN_TUI_BOOTSTRAP_JSON", "").strip()
if not bootstrap_path:
return
with open(bootstrap_path, encoding="utf-8") as bootstrap_file:
payload = json.load(bootstrap_file)
now = datetime.now(UTC)
candidate = ReleaseCandidate(
id=payload.get("candidate_id", "candidate-1"),
version=payload.get("version", "1.0.0"),
source_snapshot_ref=payload.get("source_snapshot_ref", "snapshot-ref"),
created_at=now,
created_by=payload.get("created_by", "operator"),
status=ReleaseCandidateStatus.DRAFT,
)
repository.save_candidate(candidate)
imported_artifacts = load_bootstrap_artifacts(
os.getenv("CLEAN_TUI_ARTIFACTS_JSON", "").strip(),
candidate.id,
)
for artifact in imported_artifacts:
repository.save_artifact(artifact)
if imported_artifacts:
candidate.transition_to(CandidateStatus.PREPARED)
repository.save_candidate(candidate)
registry_id = payload.get("registry_id", "REG-1")
entries = [
ResourceSourceEntry(
source_id=f"S-{index + 1}",
host=host,
protocol="https",
purpose="bootstrap",
enabled=True,
)
for index, host in enumerate(payload.get("allowed_hosts", []))
if str(host).strip()
]
if entries:
repository.save_registry(
ResourceSourceRegistry(
registry_id=registry_id,
name=payload.get("registry_name", "Bootstrap Internal Registry"),
entries=entries,
updated_at=now,
updated_by=payload.get("created_by", "operator"),
status=RegistryStatus.ACTIVE,
)
)
if entries:
repository.save_policy(
CleanProfilePolicy(
policy_id=payload.get("policy_id", "POL-ENT-CLEAN"),
policy_version=payload.get("policy_version", "1"),
profile=ProfileType.ENTERPRISE_CLEAN,
active=True,
internal_source_registry_ref=registry_id,
prohibited_artifact_categories=payload.get(
"prohibited_artifact_categories",
["test-data", "demo", "load-test"],
),
required_system_categories=payload.get(
"required_system_categories", ["core"]
),
effective_from=now,
)
)
def _resolve_candidate_id(self) -> str:
env_candidate = os.getenv("CLEAN_TUI_CANDIDATE_ID", "").strip()
if env_candidate:
return env_candidate
candidate_ids = list(self.repo.candidates.keys())
if candidate_ids:
return candidate_ids[0]
return ""
def draw_header(self, max_y: int, max_x: int):
header_text = " Enterprise Clean Release Validator (TUI) "
self.stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
# Avoid slicing if possible to satisfy Pyre, or use explicit int
centered = header_text.center(max_x)
self.stdscr.addstr(0, 0, centered[:max_x])
self.stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
candidate = self.overview.get("candidate")
candidate_text = self.candidate_id or "not-set"
profile_text = "enterprise-clean"
lifecycle = getattr(candidate, "status", "UNKNOWN")
info_line_text = (
f" │ Candidate: [{candidate_text}] Profile: [{profile_text}] "
f"Lifecycle: [{lifecycle}] Mode: [{self.mode}]"
).ljust(max_x)
self.stdscr.addstr(2, 0, info_line_text[:max_x])
def draw_checks(self):
self.stdscr.addstr(4, 3, "Checks:")
check_defs = [
(CheckStageName.DATA_PURITY, "Data Purity (no test/demo payloads)"),
(
CheckStageName.INTERNAL_SOURCES_ONLY,
"Internal Sources Only (company servers)",
),
(CheckStageName.NO_EXTERNAL_ENDPOINTS, "No External Internet Endpoints"),
(CheckStageName.MANIFEST_CONSISTENCY, "Release Manifest Consistency"),
]
row = 5
drawn_checks = {c["stage"]: c for c in self.checks_progress}
for stage, desc in check_defs:
status_text = " "
color = curses.color_pair(5)
if stage in drawn_checks:
c = drawn_checks[stage]
if c["status"] == "RUNNING":
status_text = "..."
color = curses.color_pair(4)
elif c["status"] == CheckStageStatus.PASS:
status_text = "PASS"
color = curses.color_pair(2)
elif c["status"] == CheckStageStatus.FAIL:
status_text = "FAIL"
color = curses.color_pair(3)
self.stdscr.addstr(row, 4, f"[{status_text:^4}] {desc}")
if status_text != " ":
self.stdscr.addstr(row, 50, f"{status_text:>10}", color | curses.A_BOLD)
row += 1
def draw_sources(self):
self.stdscr.addstr(12, 3, "Allowed Internal Sources:", curses.A_BOLD)
reg = self.overview.get("registry")
row = 13
if reg:
for entry in reg.entries:
self.stdscr.addstr(row, 3, f" - {entry.host}")
row += 1
else:
self.stdscr.addstr(row, 3, " - (none)")
def draw_status(self):
color = curses.color_pair(5)
if self.status == CheckFinalStatus.COMPLIANT:
color = curses.color_pair(2)
elif self.status == CheckFinalStatus.BLOCKED:
color = curses.color_pair(3)
stat_str = str(
self.status.value if hasattr(self.status, "value") else self.status
)
self.stdscr.addstr(
18, 3, f"FINAL STATUS: {stat_str.upper()}", color | curses.A_BOLD
)
if self.report_id:
self.stdscr.addstr(19, 3, f"Report ID: {self.report_id}")
approval = self.overview.get("approval")
publication = self.overview.get("publication")
if approval:
self.stdscr.addstr(20, 3, f"Approval: {approval.decision}")
if publication:
self.stdscr.addstr(20, 32, f"Publication: {publication.status}")
if self.violations_list:
self.stdscr.addstr(
21,
3,
f"Violations Details ({len(self.violations_list)} total):",
curses.color_pair(3) | curses.A_BOLD,
)
row = 22
for i, v in enumerate(self.violations_list[:5]):
v_cat = str(getattr(v, "code", "VIOLATION"))
msg = str(getattr(v, "message", "Violation detected"))
location = str(
getattr(v, "artifact_path", "")
or getattr(getattr(v, "evidence_json", {}), "get", lambda *_: "")(
"location", ""
)
)
msg_text = f"[{v_cat}] {msg} (Loc: {location})"
self.stdscr.addstr(row + i, 5, msg_text[:70], curses.color_pair(3))
if self.last_error:
self.stdscr.addstr(
27,
3,
f"Error: {self.last_error}"[:100],
curses.color_pair(3) | curses.A_BOLD,
)
def draw_footer(self, max_y: int, max_x: int):
footer_text = " F4 Build Bundle F5 Run F6 Manifest F7 Refresh F8 Approve F9 Publish F10 Exit ".center(
max_x
)
self.stdscr.attron(curses.color_pair(1))
self.stdscr.addstr(max_y - 1, 0, footer_text[:max_x])
self.stdscr.attroff(curses.color_pair(1))
# #region run_checks
# @PURPOSE: Execute compliance run via facade adapter and update UI state.
# @PRE Candidate and policy snapshots are present in repository.
# @POST UI reflects final run/report/violation state from service result.
def run_checks(self):
self.status = "RUNNING"
self.report_id = None
self.violations_list = []
self.checks_progress = []
self.last_error = None
self.refresh_screen()
try:
result = self.facade.run_compliance(
candidate_id=self.candidate_id, actor="operator"
)
except Exception as exc:
self.status = CheckFinalStatus.FAILED
self.last_error = str(exc)
self.refresh_screen()
return
self.checks_progress = [
{
"stage": stage.stage_name,
"status": CheckStageStatus.PASS
if str(stage.decision).upper() == "PASSED"
else CheckStageStatus.FAIL,
}
for stage in result.stage_runs
]
self.violations_list = result.violations
self.report_id = result.report.id if result.report is not None else None
final_status = str(result.run.final_status or "").upper()
if final_status in {"BLOCKED", CheckFinalStatus.BLOCKED.value}:
self.status = CheckFinalStatus.BLOCKED
elif final_status in {"COMPLIANT", "PASSED", CheckFinalStatus.COMPLIANT.value}:
self.status = CheckFinalStatus.COMPLIANT
else:
self.status = CheckFinalStatus.FAILED
self.refresh_overview()
self.refresh_screen()
# #endregion run_checks
def build_manifest(self):
try:
manifest = self.facade.build_manifest(
candidate_id=self.candidate_id, actor="operator"
)
self.status = "READY"
self.report_id = None
self.violations_list = []
self.checks_progress = []
self.last_error = f"Manifest built: {manifest.id}"
except Exception as exc:
self.last_error = str(exc)
self.refresh_overview()
self.refresh_screen()
def clear_history(self):
self.status = "READY"
self.report_id = None
self.violations_list = []
self.checks_progress = []
self.last_error = None
self.refresh_overview()
self.refresh_screen()
def approve_latest(self):
if not self.report_id:
self.last_error = "F8 disabled: no compliance report available"
self.refresh_screen()
return
try:
self.facade.approve_latest(candidate_id=self.candidate_id, actor="operator")
self.last_error = None
except Exception as exc:
self.last_error = str(exc)
self.refresh_overview()
self.refresh_screen()
def publish_latest(self):
if not self.report_id:
self.last_error = "F9 disabled: no compliance report available"
self.refresh_screen()
return
try:
self.facade.publish_latest(candidate_id=self.candidate_id, actor="operator")
self.last_error = None
except Exception as exc:
self.last_error = str(exc)
self.refresh_overview()
self.refresh_screen()
# #region bundle_build_mode [C:4] [TYPE Function] [SEMANTICS bundle,build,release]
# @BRIEF Interactive bundle build screen — select type, enter tag, watch live build output.
# @PRE TTY is available. Bundle type selected (1 or 2). Tag is non-empty.
# @POST Subprocess completes (success/failure). Output displayed. User returns via Esc.
# @SIDE_EFFECT Runs docker build subprocess, creates dist/docker/ files
# @RELATION CALLS -> [EXT:build.sh]
# @UX_STATE BUNDLE_MENU -> User selects bundle type and enters release tag
# @UX_STATE BUNDLE_BUILDING -> Subprocess running, real-time output displayed
# @UX_STATE BUNDLE_DONE -> Build completed with success/failure result
def bundle_build_mode(self):
"""Enter bundle build sub-loop: choose type, enter tag, watch live output."""
max_y, max_x = self.stdscr.getmaxyx()
# ── Phase 1: Bundle type selection ──
self.stdscr.clear()
self.stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
self.stdscr.addstr(0, 0, " Bundle Build ".center(max_x)[:max_x])
self.stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
self.stdscr.addstr(3, 3, "Select bundle type:", curses.A_BOLD)
self.stdscr.addstr(5, 5, "[1] Full Bundle (backend + frontend + postgres — all dependencies)")
self.stdscr.addstr(6, 5, "[2] Light Bundle (~200MB, compressed without Playwright)")
self.stdscr.addstr(8, 3, "Your choice: ")
self.stdscr.addstr(max_y - 1, 0, " Esc: Cancel ".center(max_x)[:max_x],
curses.color_pair(1))
self.stdscr.refresh()
bundle_cmd: str | None = None
bundle_label: str | None = None
while True:
k = self.stdscr.getch()
if k == ord("1"):
bundle_cmd, bundle_label = "bundle", "Full Bundle"
break
if k == ord("2"):
bundle_cmd, bundle_label = "bundle:light", "Light Bundle"
break
if k == 27: # Esc
return
# ── Phase 2: Tag input ──
self.stdscr.clear()
self.stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
self.stdscr.addstr(0, 0, f" Bundle Build — {bundle_label} ".center(max_x)[:max_x])
self.stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
self.stdscr.addstr(3, 3, f"Type: {bundle_label}", curses.A_BOLD)
self.stdscr.addstr(5, 3, "Release tag (e.g. v1.0.0): ")
self.stdscr.addstr(max_y - 1, 0, " Esc: Cancel ".center(max_x)[:max_x],
curses.color_pair(1))
self.stdscr.refresh()
curses.echo()
raw_tag = self.stdscr.getstr(5, 33, 30).decode().strip()
curses.noecho()
tag = raw_tag.strip()
if not tag:
return
# ── Phase 3: Build execution ──
build_script = os.path.join(os.path.dirname(BACKEND_ROOT), "build.sh")
self.stdscr.clear()
self.stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
self.stdscr.addstr(0, 0, f" Building {bundle_label}{tag} ".center(max_x)[:max_x])
self.stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
self.stdscr.refresh()
try:
proc = subprocess.Popen(
[build_script, bundle_cmd, tag],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
except (FileNotFoundError, OSError) as exc:
self.stdscr.addstr(3, 3, str(exc), curses.color_pair(3))
self.stdscr.addstr(max_y - 1, 0, " Press Esc to return ".center(max_x)[:max_x],
curses.color_pair(1))
self.stdscr.refresh()
while self.stdscr.getch() != 27:
pass
return
output_lines: list[str] = []
lock = threading.Lock()
build_done = threading.Event()
return_code: list[int | None] = [None]
def _reader_thread() -> None:
"""Read subprocess stdout line by line in a background thread."""
try:
for line in iter(proc.stdout.readline, ""):
with lock:
output_lines.append(line.rstrip("\n"))
proc.stdout.close()
except ValueError:
logger.debug("[reader_thread] stdout read completed (stream closed)")
return_code[0] = proc.wait()
build_done.set()
reader = threading.Thread(target=_reader_thread, daemon=True)
reader.start()
output_top = 2
output_height = max_y - 4
canceled = False
while not build_done.is_set():
with lock:
visible = output_lines[-(output_height - 1):] if output_lines else []
for i, line in enumerate(visible):
with contextlib.suppress(curses.error):
self.stdscr.addstr(output_top + i, 2, line[:max_x - 4])
with contextlib.suppress(curses.error):
self.stdscr.addstr(max_y - 2, 2, " Building... (Esc to cancel) ",
curses.color_pair(4))
self.stdscr.refresh()
self.stdscr.timeout(200)
k = self.stdscr.getch()
self.stdscr.timeout(-1)
if k == 27: # Esc
proc.terminate()
canceled = True
build_done.wait(timeout=5.0)
if proc.poll() is None:
proc.kill()
build_done.wait(timeout=2.0)
break
if not canceled:
build_done.wait()
try:
remaining = proc.stdout.read()
if remaining:
with lock:
for line in remaining.rstrip("\n").split("\n"):
if line:
output_lines.append(line)
except Exception:
logger.debug("[reader_thread] exception reading remaining stdout")
# ── Phase 4: Result summary ──
self.stdscr.clear()
ret = return_code[0]
if ret == 0:
title = f" ✅ BUILD COMPLETE — {bundle_label} / {tag} "
elif canceled:
title = f" ⏹ BUILD CANCELED — {bundle_label} / {tag} "
else:
title = f" ❌ BUILD FAILED — {bundle_label} / {tag} "
self.stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
self.stdscr.addstr(0, 0, title.center(max_x)[:max_x])
self.stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
if ret == 0:
self.stdscr.addstr(2, 3, "Build completed successfully!",
curses.color_pair(2) | curses.A_BOLD)
self.stdscr.addstr(3, 3, "Output: dist/docker/",
curses.color_pair(2))
elif canceled:
self.stdscr.addstr(2, 3, "Build canceled by user.",
curses.color_pair(4) | curses.A_BOLD)
else:
self.stdscr.addstr(2, 3, f"Build FAILED (exit code: {ret})",
curses.color_pair(3) | curses.A_BOLD)
self.stdscr.addstr(5, 3, "Last output:", curses.A_BOLD)
with lock:
tail = output_lines[-12:] if len(output_lines) > 12 else output_lines
for i, line in enumerate(tail):
with contextlib.suppress(curses.error):
self.stdscr.addstr(6 + i, 3, line[:max_x - 6])
self.stdscr.addstr(max_y - 1, 0, " Press Esc to return ".center(max_x)[:max_x],
curses.color_pair(1))
self.stdscr.refresh()
while self.stdscr.getch() != 27:
pass
# #endregion bundle_build_mode
def refresh_overview(self):
if not self.candidate_id:
self.overview = {}
return
self.overview = self.facade.get_overview(candidate_id=self.candidate_id)
def refresh_screen(self):
max_y, max_x = self.stdscr.getmaxyx()
self.stdscr.clear()
try:
self.draw_header(max_y, max_x)
self.draw_checks()
self.draw_sources()
self.draw_status()
self.draw_footer(max_y, max_x)
except Exception:
logger.debug("[refresh_screen] exception drawing TUI")
self.stdscr.refresh()
def loop(self):
self.refresh_screen()
while True:
char = self.stdscr.getch()
if char == curses.KEY_F10:
break
elif char == curses.KEY_F4:
self.bundle_build_mode()
self.refresh_screen()
elif char == curses.KEY_F5:
self.run_checks()
elif char == curses.KEY_F6:
self.build_manifest()
elif char == curses.KEY_F7:
self.clear_history()
elif char == curses.KEY_F8:
self.approve_latest()
elif char == curses.KEY_F9:
self.publish_latest()
# #endregion CleanReleaseTUI
def tui_main(stdscr: curses.window):
curses.curs_set(0) # Hide cursor
app = CleanReleaseTUI(stdscr)
app.loop()
def main() -> int:
seed_trace_id()
# TUI requires interactive terminal; headless mode must use CLI/API flow.
if not sys.stdout.isatty():
print(
"TTY is required for TUI mode. Use CLI/API workflow instead.",
file=sys.stderr,
)
return 2
try:
curses.wrapper(tui_main)
return 0
except Exception as e:
print(f"Error starting TUI: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
# #endregion CleanReleaseTuiScript