from __future__ import annotations import io from pathlib import Path import zipfile ERP_LOCAL_AGENT_VERSION = "1.26.10" ERP_LOCAL_AGENT_NAME = "ERP Local Agent" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) def build_agent_env(*, erp_base_url: str, node_code: str, node_secret: str, storage_root: str, tenant_id=None, branch_id=None, enrollment_token: str | None = None, sync_interval_seconds: int = 30, request_timeout_seconds: int = 60, tunnel_enabled: bool = True, tunnel_reconnect_seconds: int = 10) -> str: erp_base_url = (erp_base_url or "").strip().rstrip("/") if erp_base_url.startswith("http://"): host = erp_base_url[7:].split("/", 1)[0].split(":", 1)[0].lower() if host not in {"localhost", "127.0.0.1", "::1"}: erp_base_url = "https://" + erp_base_url[7:] storage_root = (storage_root or r"D:\AuditFirmStorage").strip() return ( f"ERP_BASE_URL={erp_base_url}\n" f"NODE_CODE={(node_code or '').strip()}\n" f"NODE_SECRET={(node_secret or '').strip()}\n" f"ENROLLMENT_TOKEN={(enrollment_token or '').strip()}\n" f"STORAGE_ROOT={storage_root}\n" f"TENANT_ID={'' if tenant_id is None else tenant_id}\n" f"AUDIT_FIRM_ID={'' if tenant_id is None else tenant_id}\n" f"BRANCH_ID={'' if branch_id is None else branch_id}\n" f"SYNC_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n" f"POLL_INTERVAL_SECONDS={int(sync_interval_seconds or 30)}\n" f"REQUEST_TIMEOUT_SECONDS={int(request_timeout_seconds or 60)}\n" f"TUNNEL_ENABLED={str(bool(tunnel_enabled)).lower()}\n" f"TUNNEL_RECONNECT_SECONDS={int(tunnel_reconnect_seconds or 10)}\n" "AUTO_UPDATE=true\nAUTO_INSTALL_UPDATES=true\nMANUAL_UPDATE_ONLY=false\nUPDATE_CHECK_INTERVAL_SECONDS=300\nDASHBOARD_ENABLED=true\nDASHBOARD_HOST=127.0.0.1\nDASHBOARD_PORT=8788\n" ) def _zip_info(name: str) -> zipfile.ZipInfo: info = zipfile.ZipInfo(filename=name, date_time=_DETERMINISTIC_ZIP_TIMESTAMP) info.compress_type = zipfile.ZIP_DEFLATED info.create_system = 3 info.external_attr = (0o100644 & 0xFFFF) << 16 return info def _write_zip_bytes(dst: zipfile.ZipFile, name: str, payload: bytes) -> None: dst.writestr(_zip_info(name), payload) def _build_zip(*, env_text: str | None, include_env: bool, include_admin_readme: bool) -> bytes: if not RUNTIME_ROOT.exists(): raise RuntimeError(f"ERP Local Agent runtime is missing: {RUNTIME_ROOT}") buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as dst: for path in sorted(RUNTIME_ROOT.rglob("*"), key=lambda item: item.as_posix()): if not path.is_file() or "__pycache__" in path.parts: continue _write_zip_bytes(dst, path.relative_to(RUNTIME_ROOT).as_posix(), path.read_bytes()) # Self-healing desktop launchers. Older supervisors update only the # erp_local_agent package, so place canonical copies inside the worker # package as data. The updated worker promotes them to INSTALL_ROOT. for helper_name in ("ERPAgentSupervisor.pyw", "ERPAgentDashboard.pyw"): helper = RUNTIME_ROOT / helper_name if helper.is_file(): _write_zip_bytes( dst, f"erp_local_agent/_desktop_runtime/{helper_name}", helper.read_bytes(), ) if include_env and env_text is not None: _write_zip_bytes(dst, ".env", env_text.encode("utf-8")) if include_admin_readme: text = ( f"ERP Local Agent {ERP_LOCAL_AGENT_VERSION}\n" "Existing storage, WebSocket tunnel, dashboard, Tally mapping and client .act functionality are preserved.\n" "Existing master/transaction sync and accounting tools are preserved. Existing controlled write-back now persists approved instructions in the client .act and transfers them through the shared .NET batch writer with Tally read-back verification.\nAccounting Mirror uses the proven v3.0.1 ODBC runtime for ledgers, vouchers, stock movement, HSN and GST rates.\nTally TDL is exposed through a stable local path and localhost URL so later agent updates can repair/replace the TDL without changing the Tally link.\nFull Accounting Export runs asynchronously from the ERP, publishes live progress in both ERP and the Local Agent dashboard, and refreshes the client SQLite accounting datasets after the ODBC mirror is built.\nCash Payment Compliance reads only the local SQLite Accounting Mirror after mirroring; it no longer starts a Tally extraction job or requires TallyPrime during review.\n" "Dashboard: http://127.0.0.1:8788\nDesktop dashboard is single-instance: repeated shortcut clicks focus the existing window without starting another Local Agent.\nUpdate dashboard shows check, download, install, restart and completion progress.\n" ) _write_zip_bytes(dst, "README_ERP_LOCAL_AGENT.txt", text.encode("utf-8")) return buffer.getvalue() def build_preconfigured_agent_zip(*, env_text: str, include_admin_readme: bool = False) -> bytes: return _build_zip(env_text=env_text, include_env=True, include_admin_readme=include_admin_readme) def build_agent_update_zip() -> bytes: return _build_zip(env_text=None, include_env=False, include_admin_readme=False)