mirror of
https://github.com/dredx/prole.git
synced 2026-09-27 21:04:30 +00:00
525 lines
19 KiB
Python
525 lines
19 KiB
Python
"""System environment setup screen and env.sh management."""
|
|
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox, filedialog
|
|
from installer import screen as ui
|
|
from installer.core.env import PROJECT_ROOT
|
|
|
|
|
|
class EnvironmentScreenMixin:
|
|
"""System environment setup screen and env.sh management."""
|
|
|
|
def _render_env_setup_page(self):
|
|
# Letterhead at top right
|
|
content_width = self.bg_canvas.winfo_width() or 975
|
|
right_margin = content_width - 48
|
|
|
|
ui.canvas_text(
|
|
self,
|
|
right_margin,
|
|
40,
|
|
"Prole",
|
|
fill="#6e6e73",
|
|
font=("SF Pro Text", 32, "bold"),
|
|
anchor="ne",
|
|
)
|
|
ui.canvas_text(
|
|
self,
|
|
right_margin,
|
|
85,
|
|
"Personal Infrastructure.",
|
|
fill="#6e6e73",
|
|
font=("SF Pro Text", 18),
|
|
anchor="ne",
|
|
)
|
|
|
|
# Task heading for clarity
|
|
self._render_title("Environment", y=150)
|
|
self._render_paragraph(
|
|
"Configure your Prole environment. You can accept defaults or choose custom locations. Changing PROLE_HOME will update the defaults for other paths.",
|
|
y=200,
|
|
)
|
|
|
|
defaults = self._env_defaults()
|
|
existing = self._read_existing_env()
|
|
values = {**defaults, **existing}
|
|
|
|
# Keep entry widgets to read values on Next
|
|
self._env_entries = {}
|
|
|
|
labels = [
|
|
("PROLE_HOME", "Base directory for data, configs, logs, and scripts"),
|
|
("PROLE_CONF", "Configuration directory"),
|
|
("PROLE_DATA", "Data directory"),
|
|
("PROLE_LOGS", "Logs directory"),
|
|
("PROLE_SERVICE", "System scripts directory"),
|
|
]
|
|
|
|
x_label = 48
|
|
x_field = 300
|
|
x_btn = 850
|
|
y = 280
|
|
row_h = 75
|
|
|
|
def browse(var_name: str):
|
|
from tkinter import filedialog
|
|
|
|
initial = self._env_entries[var_name].get() or Path.home()
|
|
path = filedialog.askdirectory(
|
|
title=f"Select {var_name}", initialdir=str(Path(initial).expanduser())
|
|
)
|
|
if path:
|
|
self._env_entries[var_name].delete(0, "end")
|
|
self._env_entries[var_name].insert(0, path)
|
|
if var_name == "PROLE_HOME":
|
|
# Update dependent defaults if they still match old pattern
|
|
self._update_dependent_env_paths()
|
|
|
|
# Draw rows
|
|
for key, help_text in labels:
|
|
self._canvas_items.append(
|
|
ui.canvas_text(
|
|
self,
|
|
x_label,
|
|
y,
|
|
key,
|
|
fill="black",
|
|
font=("SF Pro Text", 12, "bold"),
|
|
anchor="w",
|
|
)
|
|
)
|
|
# Use tk.Entry and place on canvas via create_window
|
|
entry = tk.Entry(
|
|
self.bg_canvas,
|
|
bg="white",
|
|
fg="black",
|
|
insertbackground="black",
|
|
highlightbackground="#CCCCCC",
|
|
highlightthickness=1,
|
|
relief="flat",
|
|
font=("SF Pro Text", 11),
|
|
)
|
|
entry.insert(0, values[key])
|
|
entry_window = self.bg_canvas.create_window(
|
|
x_field, y, window=entry, anchor="w", width=500, height=32
|
|
)
|
|
self._canvas_items.append(entry_window)
|
|
self._overlay_widgets.append(entry)
|
|
self._env_entries[key] = entry
|
|
|
|
# Use tk.Button and place on canvas via create_window
|
|
btn = tk.Button(
|
|
self.bg_canvas,
|
|
text="Browse…",
|
|
command=lambda k=key: browse(k),
|
|
bg="#F5F5DC",
|
|
fg="black",
|
|
activebackground="#E5E5D5",
|
|
highlightbackground="#F5F5DC",
|
|
highlightthickness=0,
|
|
relief="flat",
|
|
font=("SF Pro Text", 11),
|
|
padx=10,
|
|
pady=4,
|
|
)
|
|
btn_window = self.bg_canvas.create_window(
|
|
x_btn, y, window=btn, anchor="w", width=120
|
|
)
|
|
self._canvas_items.append(btn_window)
|
|
self._overlay_widgets.append(btn)
|
|
|
|
# hint text
|
|
self._canvas_items.append(
|
|
ui.canvas_text(
|
|
self,
|
|
x_field,
|
|
y + 24,
|
|
help_text,
|
|
fill="#6e6e73",
|
|
font=("SF Pro Text", 10),
|
|
anchor="nw",
|
|
)
|
|
)
|
|
y += row_h
|
|
|
|
# When PROLE_HOME changes, update dependent paths that still follow default pattern
|
|
def on_home_change(_evt=None):
|
|
self._update_dependent_env_paths()
|
|
|
|
try:
|
|
self._env_entries["PROLE_HOME"].bind("<FocusOut>", on_home_change)
|
|
except Exception:
|
|
pass
|
|
|
|
def _after_env_saved(self):
|
|
"""Reload the installer process environment after saving env.sh."""
|
|
try:
|
|
self.reload_env_from_shell()
|
|
# Ensure we capture PROLE_HOME in Global section
|
|
self.prole_cfg_data["Global"]["PROLE_HOME"] = os.environ.get(
|
|
"PROLE_HOME", ""
|
|
)
|
|
self._save_prole_cfg()
|
|
except Exception as e:
|
|
try:
|
|
messagebox.showwarning(
|
|
"Environment", f"Environment saved, but reload failed: {e}"
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def resolve_prole_home(self) -> Path:
|
|
"""Return PROLE_HOME or default to $HOME/.prole as a Path (expanded)."""
|
|
val = os.environ.get("PROLE_HOME")
|
|
if val:
|
|
try:
|
|
return Path(val).expanduser()
|
|
except Exception:
|
|
pass
|
|
return Path.home() / ".prole"
|
|
|
|
def ensure_prole_env(self) -> Path:
|
|
"""Ensure $PROLE_HOME and env.sh exist and are readable. Create minimal env if missing.
|
|
|
|
Returns the resolved PROLE_HOME Path. Raises Exception on failure.
|
|
"""
|
|
home = self.resolve_prole_home()
|
|
try:
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
except Exception as e:
|
|
raise Exception(f"Failed to create PROLE_HOME at {home}: {e}")
|
|
env_file = home / "env.sh"
|
|
if not env_file.exists():
|
|
# Construct minimal values based on defaults with this home
|
|
vals = {
|
|
"PROLE_HOME": str(home),
|
|
"PROLE_CONF": self._resolve_env_value("PROLE_CONF", str(home / "conf"))
|
|
or str(home / "conf"),
|
|
"PROLE_DATA": self._resolve_env_value("PROLE_DATA", str(home / "data"))
|
|
or str(home / "data"),
|
|
"PROLE_LOGS": self._resolve_env_value("PROLE_LOGS", str(home / "logs"))
|
|
or str(home / "logs"),
|
|
"PROLE_SERVICE": self._resolve_env_value(
|
|
"PROLE_SERVICE", str(home / "etc")
|
|
)
|
|
or str(home / "etc"),
|
|
}
|
|
self._save_env_to_file(vals)
|
|
# Validate readability
|
|
try:
|
|
data = env_file.read_text()
|
|
if not isinstance(data, str) or len(data) == 0:
|
|
raise Exception("env.sh is empty")
|
|
except Exception as e:
|
|
raise Exception(f"env.sh not readable at {env_file}: {e}")
|
|
return home
|
|
|
|
def reload_env_from_shell(self) -> None:
|
|
"""Reload environment by sourcing $PROLE_HOME/env.sh in a login shell and merging into os.environ."""
|
|
home = self.ensure_prole_env()
|
|
env_file = home / "env.sh"
|
|
# Use bash to source and print env in null-delimited form
|
|
cmd = (
|
|
f"export PROLE_HOME={shlex.quote(str(home))}; "
|
|
f"source {shlex.quote(str(env_file))}; "
|
|
"env -0"
|
|
)
|
|
try:
|
|
out = subprocess.check_output(["bash", "-lc", cmd])
|
|
except subprocess.CalledProcessError as e:
|
|
raise Exception(f"Failed to reload environment: {e}")
|
|
except Exception as e:
|
|
raise Exception(f"Failed to run bash to reload environment: {e}")
|
|
# Merge variables
|
|
try:
|
|
items = out.split(b"\x00")
|
|
for raw in items:
|
|
if not raw:
|
|
continue
|
|
try:
|
|
kv = raw.decode("utf-8", errors="ignore")
|
|
except Exception:
|
|
continue
|
|
if "=" not in kv:
|
|
continue
|
|
k, v = kv.split("=", 1)
|
|
# Avoid clobbering Python internals we rely on; safe list only
|
|
if k in ("PYTHONPATH", "PYTHONHOME"):
|
|
continue
|
|
os.environ[k] = v
|
|
except Exception:
|
|
# Best-effort; ignore merge errors
|
|
pass
|
|
|
|
def _resolve_prole_conf_dir(self) -> Path:
|
|
return self._resolve_env_dir("PROLE_CONF", "conf")
|
|
|
|
def _resolve_prole_logs_dir(self) -> Path:
|
|
return self._resolve_env_dir("PROLE_LOGS", "logs")
|
|
|
|
def _registry_log_path(self) -> Path:
|
|
logs_dir = self._resolve_prole_logs_dir()
|
|
try:
|
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
path = logs_dir / "local_registry.log"
|
|
self._last_registry_log_path = path
|
|
return path
|
|
|
|
def _common_services_log_path(self) -> Path:
|
|
logs_dir = self._resolve_prole_logs_dir()
|
|
try:
|
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
path = logs_dir / "init_common_services.log"
|
|
self._last_common_services_log_path = path
|
|
return path
|
|
|
|
def _read_log_tail(self, path: Path, max_lines: int = 400) -> str:
|
|
try:
|
|
from collections import deque
|
|
|
|
with path.open("r", encoding="utf-8", errors="ignore") as handle:
|
|
tail = deque(handle, maxlen=max_lines)
|
|
return "".join(tail)
|
|
except Exception:
|
|
return ""
|
|
|
|
def _restore_common_services_log(self, console) -> bool:
|
|
path = getattr(self, "_last_common_services_log_path", None)
|
|
if not path:
|
|
candidate = self._common_services_log_path()
|
|
if candidate.exists():
|
|
path = candidate
|
|
if not path or not path.exists():
|
|
return False
|
|
content = self._read_log_tail(path)
|
|
if not content:
|
|
return False
|
|
try:
|
|
console.clear()
|
|
console.write(f"Log file: {path}\n\n")
|
|
console.write(content)
|
|
except Exception:
|
|
return False
|
|
return True
|
|
|
|
def _ensure_prole_directories(self) -> None:
|
|
paths = {
|
|
"PROLE_HOME": self._resolve_env_value(
|
|
"PROLE_HOME", str(Path.home() / ".prole")
|
|
),
|
|
"PROLE_CONF": str(self._resolve_prole_conf_dir()),
|
|
"PROLE_DATA": str(self._resolve_env_dir("PROLE_DATA", "data")),
|
|
"PROLE_LOGS": str(self._resolve_prole_logs_dir()),
|
|
"PROLE_SERVICE": str(self._resolve_env_dir("PROLE_SERVICE", "etc")),
|
|
}
|
|
for key, raw in paths.items():
|
|
try:
|
|
Path(raw).expanduser().mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
if key not in os.environ and raw:
|
|
os.environ[key] = raw
|
|
|
|
def _record_install_log(self, path) -> None:
|
|
if not path:
|
|
return
|
|
try:
|
|
p = Path(path)
|
|
except Exception:
|
|
return
|
|
if not hasattr(self, "_install_run_log_paths"):
|
|
self._install_run_log_paths = []
|
|
sp = str(p)
|
|
if sp not in self._install_run_log_paths:
|
|
self._install_run_log_paths.append(sp)
|
|
|
|
def _update_env_namespace(self, namespace: str):
|
|
try:
|
|
existing = self._read_existing_env()
|
|
defaults = self._env_defaults()
|
|
values = {**defaults, **existing}
|
|
values["NAMESPACE"] = namespace
|
|
self._save_env_to_file(values)
|
|
os.environ["NAMESPACE"] = namespace
|
|
except Exception as e:
|
|
print(f"[WARN] Failed to update env.sh namespace: {e}")
|
|
|
|
def _save_env_to_file(self, values: dict):
|
|
home = Path(values["PROLE_HOME"]).expanduser()
|
|
# ensure base dir exists
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
# ensure subdirs exist
|
|
for key in ("PROLE_CONF", "PROLE_DATA", "PROLE_LOGS", "PROLE_SERVICE"):
|
|
try:
|
|
Path(values[key]).expanduser().mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
# write env.sh atomically
|
|
content = []
|
|
# Executable wrapper + sourceable config
|
|
content.append("#!/usr/bin/env bash")
|
|
content.append("# Prole environment configuration")
|
|
content.append(
|
|
"# This file is generated by the installer. Source it in new shells, or execute as a wrapper:"
|
|
)
|
|
content.append('# "$PROLE_HOME/env.sh" <command> [args…]')
|
|
content.append("# shellcheck shell=bash")
|
|
# Core PROLE_* directories
|
|
for k in (
|
|
"PROLE_HOME",
|
|
"PROLE_CONF",
|
|
"PROLE_DATA",
|
|
"PROLE_LOGS",
|
|
"PROLE_SERVICE",
|
|
):
|
|
content.append(f'export {k}="{values[k]}"')
|
|
if values.get("NAMESPACE"):
|
|
content.append(f'export NAMESPACE="{values["NAMESPACE"]}"')
|
|
content.append("")
|
|
# Ensure PATH contains common locations and $PROLE_HOME/bin (POSIX sh compatible)
|
|
content.append("# Ensure PATH works for GUI-launched shells (Docker, etc.)")
|
|
content.append(
|
|
'_prole_add_path() { case ":${PATH}:" in *":$1:"*) ;; *) PATH="$1:${PATH:-}" ;; esac; }'
|
|
)
|
|
content.append('_prole_add_path "$PROLE_HOME/bin"')
|
|
content.append('_prole_add_path "/opt/homebrew/bin"')
|
|
content.append('_prole_add_path "/usr/local/bin"')
|
|
content.append('_prole_add_path "/usr/bin"')
|
|
content.append('_prole_add_path "/bin"')
|
|
content.append('_prole_add_path "/usr/sbin"')
|
|
content.append('_prole_add_path "/sbin"')
|
|
content.append("export PATH")
|
|
content.append("")
|
|
content.append("# Add custom paths below if needed (examples):")
|
|
content.append("")
|
|
content.append(
|
|
"# If executed with arguments (and not sourced), run them under this environment"
|
|
)
|
|
content.append('if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [ "$#" -gt 0 ]; then')
|
|
content.append(' exec "$@"')
|
|
content.append("fi")
|
|
|
|
new_content = "\n".join(content) + "\n"
|
|
out = home / "env.sh"
|
|
|
|
should_write = True
|
|
if out.exists():
|
|
if out.read_text() == new_content:
|
|
should_write = False
|
|
# print(f"[OK] {out} is up to date.")
|
|
|
|
if should_write:
|
|
tmp = home / "env.sh.tmp"
|
|
tmp.write_text(new_content)
|
|
tmp.replace(out)
|
|
try:
|
|
os.chmod(out, 0o755)
|
|
except Exception:
|
|
pass
|
|
# print(f"[OK] Wrote {out}")
|
|
|
|
# After creating env.sh, deploy additional resources as requested:
|
|
# 1) Deploy init-port-forward.sh into $PROLE_HOME (and ensure it's executable)
|
|
# 2) Copy contents of src/prole/etc (or fallback to top-level etc) into $PROLE_SERVICE
|
|
try:
|
|
prole_home = Path(values["PROLE_HOME"]).expanduser()
|
|
prole_service = Path(values["PROLE_SERVICE"]).expanduser()
|
|
|
|
# Determine source for init-port-forward.sh
|
|
# Preferred location under repo: PROJECT_ROOT/src/prole/etc/init-port-forward.sh
|
|
init_pf_src_candidates = [
|
|
PROJECT_ROOT / "src" / "prole" / "etc" / "init-port-forward.sh",
|
|
PROJECT_ROOT / "etc" / "init-port-forward.sh",
|
|
]
|
|
init_pf_src = next((p for p in init_pf_src_candidates if p.exists()), None)
|
|
if init_pf_src is not None:
|
|
init_pf_dst = prole_home / "init-port-forward.sh"
|
|
try:
|
|
data = init_pf_src.read_bytes()
|
|
init_pf_dst.write_bytes(data)
|
|
os.chmod(init_pf_dst, 0o755)
|
|
except Exception:
|
|
# best effort, ignore errors
|
|
pass
|
|
|
|
# Determine etc directory source
|
|
etc_src_candidates = [
|
|
PROJECT_ROOT / "src" / "prole" / "etc",
|
|
PROJECT_ROOT / "etc",
|
|
]
|
|
etc_src = next(
|
|
(p for p in etc_src_candidates if p.exists() and p.is_dir()), None
|
|
)
|
|
if etc_src is not None:
|
|
try:
|
|
prole_service.mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
|
|
# Copy files (non-recursive: contents of etc root). If subdirectories exist, copy them recursively.
|
|
import shutil
|
|
|
|
def _copy_item(src_path: Path, dst_path: Path):
|
|
try:
|
|
if src_path.is_dir():
|
|
# copy directory tree
|
|
if dst_path.exists():
|
|
# remove then copy to keep in sync
|
|
shutil.rmtree(dst_path, ignore_errors=True)
|
|
shutil.copytree(src_path, dst_path)
|
|
else:
|
|
shutil.copy2(src_path, dst_path)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
for item in etc_src.iterdir():
|
|
_copy_item(item, prole_service / item.name)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
# overall best-effort; do not fail env creation if copies fail
|
|
pass
|
|
|
|
def _update_dependent_env_paths(self):
|
|
try:
|
|
new_home = Path(self._env_entries["PROLE_HOME"].get()).expanduser()
|
|
except Exception:
|
|
return
|
|
mapping = {
|
|
"PROLE_CONF": new_home / "conf",
|
|
"PROLE_DATA": new_home / "data",
|
|
"PROLE_LOGS": new_home / "logs",
|
|
"PROLE_SERVICE": new_home / "etc",
|
|
}
|
|
# Update entries if they are empty or previously matched the old base
|
|
for key, new_path in mapping.items():
|
|
ent = self._env_entries.get(key)
|
|
if not ent:
|
|
continue
|
|
cur = ent.get().strip()
|
|
if not cur:
|
|
ent.delete(0, "end")
|
|
ent.insert(0, str(new_path))
|
|
continue
|
|
# If cur looked like old_home/<suffix>, update it
|
|
try:
|
|
# detect suffix
|
|
suffix = new_path.name
|
|
if cur.endswith("/" + suffix):
|
|
# replace base path
|
|
ent.delete(0, "end")
|
|
ent.insert(0, str(new_path))
|
|
except Exception:
|
|
pass
|
|
# done
|