Files
coreci da15bda6d2 docs(milestone): complete cli-packaging
---ci---
phase: 3
milestone: v0.2
status: complete
requirements:
  covered: [REQ-21, REQ-22, REQ-23]
  partial: []
---/ci---
2026-10-11 00:32:26 +00:00

126 lines
4.9 KiB
Python

# coreci-broker CLI boot shim; staged by scripts/build_cli.py as the root
# __main__.py of the coreci-broker-vX.Y.Z.pyz zipapp (the documented mode —
# running this file unzipped from a source tree is a guarded fallback).
#
# Boot sequence: (1) --version prints _cli_version.VERSION and exits 0 before
# any broker import; (2) the state dir is resolved exactly like the broker
# resolves it (BROKER_ENV_PATH-anchored; relative default paths stay
# CWD-relative — the broker's path contract is inherited unchanged);
# (3) bundled assets are materialized from the running archive
# (zipfile.ZipFile(sys.path[0])) into
# <state>/.coreci_broker_assets/<VERSION>/{ui,static} — ui/ receives the
# bundled coreci_broker_ui/ pages, static/ receives the bundled
# coreci_broker_client.py — via a temp dir plus os.replace (versioned dirs;
# same-version overwrite self-repairs; other versions are never cleaned);
# (4) BROKER_UI_ROOT/BROKER_STATIC_ROOT are pointed at the materialized
# dirs only when the key is absent from both the process env and the user
# .env (user wins); (5) coreci_broker.run_standalone() boots the broker.
# Guard: when sys.path[0] is a directory (or empty) this shim is running
# unzipped from a source tree — materialization is skipped and the broker's
# repo-relative defaults apply.
#
# Private-API coupling (grill G-018, tracked in P3-02): this shim imports
# coreci_broker._parse_env_file and coreci_broker.run_standalone —
# module-level but private-surfaced broker APIs (coreci_broker.py is frozen
# at 1390 lines). Any future broker refactor that removes or renames them
# breaks packaged boots loudly (ImportError/AttributeError); that breakage
# is accepted — the fix is to update this shim and re-run the v0.2 packaged
# acceptance (tests/test_cli_packaging.py).
import os
import shutil
import sys
import tempfile
import zipfile
ASSET_DIR_NAME = ".coreci_broker_assets"
def _version_requested():
return "--version" in sys.argv[1:]
def _resolve_state_dir():
env_path = os.environ.get("BROKER_ENV_PATH") or ".env"
return os.path.dirname(os.path.abspath(env_path)) or os.getcwd()
def _safe_relative(name):
if not name or "\x00" in name or "\\" in name or name.startswith("/"):
return None
parts = name.split("/")
if ".." in parts or any(part.startswith(".") for part in parts):
return None
return name
def _write_asset(target_dir, relative, payload):
target = os.path.join(target_dir, relative)
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "wb") as handle:
handle.write(payload)
def _materialize_assets(archive_path, state_dir):
import _cli_version
base_dir = os.path.join(state_dir, ASSET_DIR_NAME, _cli_version.VERSION)
parent_dir = os.path.dirname(base_dir)
os.makedirs(parent_dir, exist_ok=True)
tmp_dir = tempfile.mkdtemp(dir=parent_dir, prefix=".tmp-")
try:
ui_dir = os.path.join(tmp_dir, "ui")
static_dir = os.path.join(tmp_dir, "static")
os.makedirs(ui_dir)
os.makedirs(static_dir)
with zipfile.ZipFile(archive_path) as archive:
for name in archive.namelist():
if name == "coreci_broker_client.py":
_write_asset(static_dir, name, archive.read(name))
elif name.startswith("coreci_broker_ui/") and not name.endswith("/"):
relative = _safe_relative(name[len("coreci_broker_ui/"):])
if relative is not None:
_write_asset(ui_dir, relative, archive.read(name))
if os.path.isdir(base_dir):
stale_dir = base_dir + ".stale"
shutil.rmtree(stale_dir, ignore_errors=True)
os.replace(base_dir, stale_dir)
try:
os.replace(tmp_dir, base_dir)
except OSError:
os.replace(stale_dir, base_dir)
raise
shutil.rmtree(stale_dir, ignore_errors=True)
else:
os.replace(tmp_dir, base_dir)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
return {"ui": os.path.join(base_dir, "ui"), "static": os.path.join(base_dir, "static")}
def _apply_asset_roots(roots, env_path):
import coreci_broker
file_values = dict(coreci_broker._parse_env_file(env_path))
for key, name in (("BROKER_UI_ROOT", "ui"), ("BROKER_STATIC_ROOT", "static")):
if key not in os.environ and key not in file_values:
os.environ[key] = roots[name]
def main():
if _version_requested():
import _cli_version
print(_cli_version.VERSION)
return 0
archive_path = sys.path[0]
if archive_path and not os.path.isdir(archive_path):
env_path = os.environ.get("BROKER_ENV_PATH") or ".env"
roots = _materialize_assets(archive_path, _resolve_state_dir())
_apply_asset_roots(roots, env_path)
import coreci_broker
coreci_broker.run_standalone()
return 0
if __name__ == "__main__":
sys.exit(main())