"""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('', 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" [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/, 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