Compare commits
1 Commits
master
...
mac-launchers
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a37a4ffbf |
@@ -6,6 +6,9 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
|
|
||||||
|
# Entorno virtual de macOS/Linux (lo crea setup_mac.command)
|
||||||
|
.venv/
|
||||||
|
|
||||||
# Todo lo que generan la app y los scripts vive bajo generated/ (ver paths.py)
|
# Todo lo que generan la app y los scripts vive bajo generated/ (ver paths.py)
|
||||||
generated/
|
generated/
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
- Install deps with `python -m pip install -r requirements.txt`.
|
- Install deps with `python -m pip install -r requirements.txt`.
|
||||||
- Run the local app with `python main.py`; it serves FastAPI/Uvicorn at `http://127.0.0.1:8000` with reload enabled.
|
- Run the local app with `python main.py`; it serves FastAPI/Uvicorn at `http://127.0.0.1:8000` with reload enabled.
|
||||||
- On Windows, `start.bat` runs `python main.py` in a new window and opens the browser; `stop.bat` kills any process using port `8000`.
|
- On Windows, `start.bat` runs `python main.py` in a new window and opens the browser; `stop.bat` kills any process using port `8000`.
|
||||||
|
- On macOS/Linux, the double-clickable `.command` files mirror the `.bat`s: run `setup_mac.command` once to bootstrap a `.venv` (Python 3.10+) and deps, then `start.command` / `stop.command` / `restart.command` / `start_persistent_profile.command`. They delegate the safe preflight/stop logic to the now cross-platform `runtime_control.py`.
|
||||||
- There is no test, lint, or formatter config in this repo. For a syntax-only check, run `python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py` and add specific scripts as needed.
|
- There is no test, lint, or formatter config in this repo. For a syntax-only check, run `python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py` and add specific scripts as needed.
|
||||||
- Run focused utility scripts directly, for example `python scripts\mp_contact_search.py <query>` or `python scripts\mp_opportunity_search.py <query-or-status>`.
|
- Run focused utility scripts directly, for example `python scripts\mp_contact_search.py <query>` or `python scripts\mp_opportunity_search.py <query-or-status>`.
|
||||||
- Global dashboard sync parallelism is controlled with `SYNC_ENGINE_MAX_WORKERS`; default is `12`, hard maximum is `20`. It affects the `Sincronizar Todo` button and processes multiple GHL locations in parallel.
|
- Global dashboard sync parallelism is controlled with `SYNC_ENGINE_MAX_WORKERS`; default is `12`, hard maximum is `20`. It affects the `Sincronizar Todo` button and processes multiple GHL locations in parallel.
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
- `python -m pip install -r requirements.txt` — instalar dependencias.
|
- `python -m pip install -r requirements.txt` — instalar dependencias.
|
||||||
- `python main.py` — levanta FastAPI/Uvicorn en `http://127.0.0.1:8000` con reload.
|
- `python main.py` — levanta FastAPI/Uvicorn en `http://127.0.0.1:8000` con reload.
|
||||||
- `start.bat` / `stop.bat` / `restart.bat` (Windows) — lanzan/matan/reinician la app y liberan el puerto 8000. `restart.bat` detecta automáticamente si estaba en modo normal o perfil persistente (lee `generated/runtime/last_mode`), mata Chromium zombies y limpia batch files huérfanos en `generated/runtime/batch/`.
|
- `start.bat` / `stop.bat` / `restart.bat` (Windows) — lanzan/matan/reinician la app y liberan el puerto 8000. `restart.bat` detecta automáticamente si estaba en modo normal o perfil persistente (lee `generated/runtime/last_mode`), mata Chromium zombies y limpia batch files huérfanos en `generated/runtime/batch/`.
|
||||||
|
- **macOS / Linux** — equivalentes doble-clickeables en Finder con extensión `.command`:
|
||||||
|
- `setup_mac.command` — inicialización con un click (NO tiene equivalente Windows): detecta Python 3.10+, crea el venv `.venv`, instala `requirements.txt` y el Chromium de Playwright, y copia `.env.example` a `.env`. Idempotente.
|
||||||
|
- `start.command` / `stop.command` / `restart.command` / `start_persistent_profile.command` — espejo de los `.bat`. Lanzan el server con `nohup` en segundo plano (logs en `generated/logs/server.out`) usando el python del `.venv`. `stop.command` acepta `--force` igual que el `.bat`.
|
||||||
|
- `mp_common.sh` — helper compartido (sourced, no se ejecuta solo): resuelve la raíz del proyecto, localiza el python del venv y define los banners/utilidades. La lógica segura de preflight/stop la delegan todos a `runtime_control.py`, que ahora es cross-platform (`lsof`/`ps`/`pgrep`/`kill` en POSIX, `netstat`/PowerShell/`taskkill` en Windows; ver `IS_WINDOWS`).
|
||||||
- `python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py paths.py` — único "lint" disponible (no hay test/lint/format configurado).
|
- `python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py paths.py` — único "lint" disponible (no hay test/lint/format configurado).
|
||||||
- Scripts de utilidad se corren directo: `python scripts\<nombre>.py [args]`. La mayoría leen `generated/data/mp_manager.sqlite` (vía `paths.DB_PATH`) y asumen una sync previa.
|
- Scripts de utilidad se corren directo: `python scripts\<nombre>.py [args]`. La mayoría leen `generated/data/mp_manager.sqlite` (vía `paths.DB_PATH`) y asumen una sync previa.
|
||||||
- Variables de entorno:
|
- Variables de entorno:
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Los scripts soportan dos modos de persistencia. Se elige con la variable de ento
|
|||||||
|
|
||||||
### Modo 2 — Perfil de Chrome persistente
|
### Modo 2 — Perfil de Chrome persistente
|
||||||
|
|
||||||
- **Cuándo se usa**: cuando defines `GHL_BROWSER_PROFILE_DIR` apuntando a un directorio. Lo más fácil es lanzar el servidor con [start_persistent_profile.bat](../start_persistent_profile.bat).
|
- **Cuándo se usa**: cuando defines `GHL_BROWSER_PROFILE_DIR` apuntando a un directorio. Lo más fácil es lanzar el servidor con [start_persistent_profile.bat](../start_persistent_profile.bat) (Windows) o [start_persistent_profile.command](../start_persistent_profile.command) (macOS/Linux).
|
||||||
- **Cómo funciona**: Playwright usa `launch_persistent_context()` con un perfil completo en disco — igual que un Chrome real. Persiste cookies HttpOnly, IndexedDB, cache, localStorage, service workers.
|
- **Cómo funciona**: Playwright usa `launch_persistent_context()` con un perfil completo en disco — igual que un Chrome real. Persiste cookies HttpOnly, IndexedDB, cache, localStorage, service workers.
|
||||||
- **Pros**: GHL trata el perfil como un "dispositivo" estable. Sesión mucho más duradera. Login dura semanas en vez de horas/días.
|
- **Pros**: GHL trata el perfil como un "dispositivo" estable. Sesión mucho más duradera. Login dura semanas en vez de horas/días.
|
||||||
- **Cons**: **no puedes correr dos scripts en paralelo** contra el mismo perfil — Chrome bloquea el directorio mientras un proceso lo usa. Si necesitas ejecuciones concurrentes (raro en este repo), no uses este modo.
|
- **Cons**: **no puedes correr dos scripts en paralelo** contra el mismo perfil — Chrome bloquea el directorio mientras un proceso lo usa. Si necesitas ejecuciones concurrentes (raro en este repo), no uses este modo.
|
||||||
|
|||||||
Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# mp_common.sh — utilidades compartidas por los launchers de macOS/Linux
|
||||||
|
# (start.command, stop.command, restart.command, start_persistent_profile.command
|
||||||
|
# y setup_mac.command).
|
||||||
|
#
|
||||||
|
# No se ejecuta directamente: los demás scripts lo cargan con `source`.
|
||||||
|
# Equivale a la lógica común que en Windows vive embebida en los .bat.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Raíz del proyecto = carpeta donde vive este archivo. Resuelve symlinks para
|
||||||
|
# que funcione aunque el .command se invoque desde otra ruta.
|
||||||
|
SOURCE="${BASH_SOURCE[0]}"
|
||||||
|
while [ -h "$SOURCE" ]; do
|
||||||
|
DIR="$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)"
|
||||||
|
SOURCE="$(readlink "$SOURCE")"
|
||||||
|
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
|
||||||
|
done
|
||||||
|
PROJECT_DIR="$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)"
|
||||||
|
cd "$PROJECT_DIR" || exit 1
|
||||||
|
|
||||||
|
VENV_DIR="$PROJECT_DIR/.venv"
|
||||||
|
VENV_PY="$VENV_DIR/bin/python"
|
||||||
|
|
||||||
|
# Versión mínima de Python que exige el proyecto (3.10+).
|
||||||
|
PY_MIN_MAJOR=3
|
||||||
|
PY_MIN_MINOR=10
|
||||||
|
|
||||||
|
# Colorea solo si la salida es una terminal interactiva.
|
||||||
|
if [ -t 1 ]; then
|
||||||
|
C_RESET="\033[0m"; C_BOLD="\033[1m"; C_RED="\033[31m"; C_YEL="\033[33m"; C_GRN="\033[32m"
|
||||||
|
else
|
||||||
|
C_RESET=""; C_BOLD=""; C_RED=""; C_YEL=""; C_GRN=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
info() { printf "${C_BOLD}[SISTEMA]${C_RESET} %s\n" "$1"; }
|
||||||
|
ok() { printf "${C_GRN}[OK]${C_RESET} %s\n" "$1"; }
|
||||||
|
warn() { printf "${C_YEL}[ADVERTENCIA]${C_RESET} %s\n" "$1"; }
|
||||||
|
err() { printf "${C_RED}[ERROR]${C_RESET} %s\n" "$1" >&2; }
|
||||||
|
|
||||||
|
# Imprime un encabezado tipo banner como los .bat de Windows.
|
||||||
|
banner() {
|
||||||
|
echo "==================================================="
|
||||||
|
echo " $1"
|
||||||
|
echo "==================================================="
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
# True si "$1 --version" reporta >= PY_MIN.
|
||||||
|
_py_is_recent_enough() {
|
||||||
|
local cmd="$1"
|
||||||
|
"$cmd" -c "import sys; sys.exit(0 if sys.version_info[:2] >= ($PY_MIN_MAJOR, $PY_MIN_MINOR) else 1)" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Localiza un intérprete del sistema 3.10+ para crear el venv. Prueba nombres
|
||||||
|
# versionados explícitos (más fiables que 'python3' a secas en macOS, donde el
|
||||||
|
# 'python3' del sistema suele ser viejo). Imprime la ruta o vacío.
|
||||||
|
find_system_python() {
|
||||||
|
local candidates=(python3.13 python3.12 python3.11 python3.10 python3 python)
|
||||||
|
for c in "${candidates[@]}"; do
|
||||||
|
if command -v "$c" >/dev/null 2>&1 && _py_is_recent_enough "$c"; then
|
||||||
|
command -v "$c"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Devuelve el python del venv si existe; si no, vacío. Los launchers que
|
||||||
|
# requieren dependencias instaladas deben llamar a require_venv en su lugar.
|
||||||
|
venv_python() {
|
||||||
|
if [ -x "$VENV_PY" ]; then
|
||||||
|
echo "$VENV_PY"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Garantiza que el venv exista; si no, da instrucciones y aborta. Imprime la
|
||||||
|
# ruta del intérprete del venv por stdout (para capturarla con $(...)).
|
||||||
|
require_venv() {
|
||||||
|
if [ ! -x "$VENV_PY" ]; then
|
||||||
|
err "No se encontró el entorno virtual (.venv)."
|
||||||
|
err "Ejecuta primero (doble clic): setup_mac.command"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "$VENV_PY"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pausa al final cuando el script se abrió con doble clic en Finder, para que la
|
||||||
|
# ventana de Terminal no se cierre de golpe y el usuario pueda leer el resultado.
|
||||||
|
# Si se ejecutó desde una terminal interactiva (uso avanzado), no estorba.
|
||||||
|
hold_window() {
|
||||||
|
echo
|
||||||
|
read -r -p "Presiona ENTER para cerrar esta ventana..." _ || true
|
||||||
|
}
|
||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# restart.command — Equivalente macOS/Linux de restart.bat.
|
||||||
|
# Reinicia el servidor detectando automáticamente si estaba en modo normal o
|
||||||
|
# perfil persistente (lee generated/runtime/last_mode), mata Chromium zombies
|
||||||
|
# de Playwright y limpia batch files huérfanos.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
set -euo pipefail
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/mp_common.sh"
|
||||||
|
|
||||||
|
banner "MP Manager - Reiniciando Servidor"
|
||||||
|
|
||||||
|
PY="$(require_venv)" || { hold_window; exit 1; }
|
||||||
|
|
||||||
|
# --- 1. Determinar modo activo (normal / persistent) ----------------------
|
||||||
|
MODE="normal"
|
||||||
|
LAST_MODE_FILE="generated/runtime/last_mode"
|
||||||
|
if [ -f "$LAST_MODE_FILE" ]; then
|
||||||
|
MODE="$(tr -d '[:space:]' < "$LAST_MODE_FILE")"
|
||||||
|
fi
|
||||||
|
if [ "$MODE" != "normal" ] && [ "$MODE" != "persistent" ]; then
|
||||||
|
MODE="normal"
|
||||||
|
fi
|
||||||
|
info "Modo detectado: $MODE"
|
||||||
|
|
||||||
|
# --- 2. Detener el server actual de forma segura --------------------------
|
||||||
|
set +e
|
||||||
|
"$PY" runtime_control.py stop --force
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# --- 3. Limpiar Chromium headless huérfanos de Playwright -----------------
|
||||||
|
# Solo matamos procesos cuya línea de comando apunta al Chromium de Playwright
|
||||||
|
# (ms-playwright / chrome-headless-shell), NUNCA el Google Chrome del usuario.
|
||||||
|
info "Limpiando zombies de Chromium headless de Playwright si los hay..."
|
||||||
|
pkill -f "ms-playwright.*[Cc]hromium" 2>/dev/null || true
|
||||||
|
pkill -f "chrome-headless-shell" 2>/dev/null || true
|
||||||
|
|
||||||
|
# --- 4. Limpiar batch files interrumpidos del runtime ---------------------
|
||||||
|
if compgen -G "generated/runtime/batch/_bulk_batch_*.json" >/dev/null; then
|
||||||
|
info "Limpiando batch files huérfanos..."
|
||||||
|
rm -f generated/runtime/batch/_bulk_batch_*.json
|
||||||
|
fi
|
||||||
|
rm -f generated/runtime/batch/_test_batch.json 2>/dev/null || true
|
||||||
|
|
||||||
|
# --- 5. Pequeña espera para liberar el puerto -----------------------------
|
||||||
|
info "Esperando 2 segundos para liberar puerto..."
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# --- 6. Relanzar en el modo correcto --------------------------------------
|
||||||
|
mkdir -p generated/runtime generated/logs
|
||||||
|
LOG="$PROJECT_DIR/generated/logs/server.out"
|
||||||
|
|
||||||
|
if [ "$MODE" = "persistent" ]; then
|
||||||
|
export GHL_BROWSER_PROFILE_DIR="$PROJECT_DIR/generated/browser/profile"
|
||||||
|
info "Relanzando en modo PERFIL PERSISTENTE."
|
||||||
|
info "GHL_BROWSER_PROFILE_DIR=$GHL_BROWSER_PROFILE_DIR"
|
||||||
|
if [ ! -d "$GHL_BROWSER_PROFILE_DIR" ]; then
|
||||||
|
echo
|
||||||
|
warn "El perfil persistente aún no existe."
|
||||||
|
warn 'Cuando arranque, usa "Renovar sesión Bucéfalo" en el dashboard.'
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
echo "persistent" > "$LAST_MODE_FILE"
|
||||||
|
else
|
||||||
|
info "Relanzando en modo NORMAL."
|
||||||
|
echo "normal" > "$LAST_MODE_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
nohup "$PY" "$PROJECT_DIR/main.py" >> "$LOG" 2>&1 &
|
||||||
|
disown || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==================================================="
|
||||||
|
echo " Servidor reiniciado en modo $MODE."
|
||||||
|
echo " Logs: generated/logs/server.out"
|
||||||
|
echo " Para detener: stop.command"
|
||||||
|
echo "==================================================="
|
||||||
|
hold_window
|
||||||
+118
-25
@@ -7,9 +7,10 @@ Centraliza la lógica de:
|
|||||||
* apagar el servidor de forma segura (sin matar procesos ajenos),
|
* apagar el servidor de forma segura (sin matar procesos ajenos),
|
||||||
* esperar a que el server termine de arrancar (server_info.json escrito).
|
* esperar a que el server termine de arrancar (server_info.json escrito).
|
||||||
|
|
||||||
Pensado para que los batch files (`start.bat`, `stop.bat`, `restart.bat`) lo
|
Pensado para que los launchers (Windows: `start.bat`/`stop.bat`/`restart.bat`;
|
||||||
invoquen como CLI sin tener que hacer parsing de JSON o validaciones complejas
|
macOS/Linux: `start.command`/`stop.command`/`restart.command`) lo invoquen como
|
||||||
en lenguaje batch.
|
CLI sin tener que hacer parsing de JSON o validaciones complejas en shell. La
|
||||||
|
lógica de OS (puertos, PIDs, kill) es cross-platform: ver `IS_WINDOWS`.
|
||||||
|
|
||||||
Uso CLI:
|
Uso CLI:
|
||||||
python runtime_control.py status # imprime estado actual
|
python runtime_control.py status # imprime estado actual
|
||||||
@@ -22,6 +23,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -35,6 +37,11 @@ import paths
|
|||||||
# proyecto Python). Lo buscamos en la línea de comando del proceso.
|
# proyecto Python). Lo buscamos en la línea de comando del proceso.
|
||||||
PROJECT_MARKER = os.path.normcase(paths.BASE_DIR)
|
PROJECT_MARKER = os.path.normcase(paths.BASE_DIR)
|
||||||
|
|
||||||
|
# Las primitivas de OS (descubrir quién ocupa un puerto, leer la línea de comando
|
||||||
|
# de un PID, matarlo) difieren entre Windows y POSIX (macOS/Linux). El resto del
|
||||||
|
# módulo es agnóstico: dispatcha según esta bandera.
|
||||||
|
IS_WINDOWS = os.name == "nt"
|
||||||
|
|
||||||
|
|
||||||
def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
@@ -47,6 +54,12 @@ def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
|||||||
|
|
||||||
def get_pid_listening_on(port: int) -> Optional[int]:
|
def get_pid_listening_on(port: int) -> Optional[int]:
|
||||||
"""Devuelve el PID que escucha en `port` (LISTENING), o None si nadie."""
|
"""Devuelve el PID que escucha en `port` (LISTENING), o None si nadie."""
|
||||||
|
if IS_WINDOWS:
|
||||||
|
return _get_pid_listening_on_windows(port)
|
||||||
|
return _get_pid_listening_on_posix(port)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_pid_listening_on_windows(port: int) -> Optional[int]:
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(
|
out = subprocess.check_output(
|
||||||
["netstat", "-ano"], text=True, stderr=subprocess.DEVNULL
|
["netstat", "-ano"], text=True, stderr=subprocess.DEVNULL
|
||||||
@@ -65,6 +78,27 @@ def get_pid_listening_on(port: int) -> Optional[int]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_pid_listening_on_posix(port: int) -> Optional[int]:
|
||||||
|
"""macOS/Linux: `lsof` lista el/los PID que escuchan en el puerto.
|
||||||
|
|
||||||
|
`-sTCP:LISTEN` acota a sockets en estado LISTEN, `-t` devuelve solo PIDs.
|
||||||
|
Si hay varios (p.ej. el reloader de uvicorn y su worker comparten socket),
|
||||||
|
devolvemos el primero; `_kill_tree` se encarga del árbol completo.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
|
||||||
|
text=True,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
for line in out.split():
|
||||||
|
if line.strip().isdigit():
|
||||||
|
return int(line.strip())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _run_powershell(ps_command: str) -> Optional[str]:
|
def _run_powershell(ps_command: str) -> Optional[str]:
|
||||||
"""Ejecuta un comando PowerShell y devuelve stdout (stripped) o None si falla.
|
"""Ejecuta un comando PowerShell y devuelve stdout (stripped) o None si falla.
|
||||||
|
|
||||||
@@ -85,18 +119,43 @@ def _run_powershell(ps_command: str) -> Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def get_process_cmdline(pid: int) -> Optional[str]:
|
def get_process_cmdline(pid: int) -> Optional[str]:
|
||||||
"""Devuelve la línea de comando de un PID en Windows, o None si no existe."""
|
"""Devuelve la línea de comando completa de un PID, o None si no existe."""
|
||||||
return _run_powershell(
|
if IS_WINDOWS:
|
||||||
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}' -ErrorAction SilentlyContinue).CommandLine"
|
return _run_powershell(
|
||||||
)
|
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}' -ErrorAction SilentlyContinue).CommandLine"
|
||||||
|
)
|
||||||
|
# POSIX (macOS/Linux): `ps -o command=` imprime la línea de comando completa
|
||||||
|
# sin encabezado. En macOS el límite por defecto es amplio; suficiente para
|
||||||
|
# contener la ruta absoluta a main.py que es lo que buscamos.
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
["ps", "-p", str(pid), "-o", "command="],
|
||||||
|
text=True,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return out.strip() or None
|
||||||
|
|
||||||
|
|
||||||
def pid_is_alive(pid: int) -> bool:
|
def pid_is_alive(pid: int) -> bool:
|
||||||
"""True si el PID existe. Más confiable que parsear tasklist."""
|
"""True si el PID existe."""
|
||||||
result = _run_powershell(
|
if IS_WINDOWS:
|
||||||
f"if (Get-Process -Id {pid} -ErrorAction SilentlyContinue) {{ 'yes' }} else {{ 'no' }}"
|
result = _run_powershell(
|
||||||
)
|
f"if (Get-Process -Id {pid} -ErrorAction SilentlyContinue) {{ 'yes' }} else {{ 'no' }}"
|
||||||
return result == "yes"
|
)
|
||||||
|
return result == "yes"
|
||||||
|
# POSIX: señal 0 no mata; solo verifica existencia/permisos del proceso.
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
# Existe pero es de otro usuario; para nuestros fines, está vivo.
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def pid_belongs_to_mp_manager(pid: int) -> bool:
|
def pid_belongs_to_mp_manager(pid: int) -> bool:
|
||||||
@@ -106,7 +165,7 @@ def pid_belongs_to_mp_manager(pid: int) -> bool:
|
|||||||
registró este PID. Como ese archivo SOLO lo escribe `main.py` de este proyecto,
|
registró este PID. Como ese archivo SOLO lo escribe `main.py` de este proyecto,
|
||||||
el match es prueba inequívoca de identidad.
|
el match es prueba inequívoca de identidad.
|
||||||
|
|
||||||
Fuente secundaria (cmdline): si los .bat lanzan `python "<ruta absoluta>/main.py"`,
|
Fuente secundaria (cmdline): si los launchers lanzan `python "<ruta absoluta>/main.py"`,
|
||||||
el path del proyecto queda embebido en la línea de comando del proceso y podemos
|
el path del proyecto queda embebido en la línea de comando del proceso y podemos
|
||||||
reconocerlo aunque el server_info.json esté ausente o stale.
|
reconocerlo aunque el server_info.json esté ausente o stale.
|
||||||
"""
|
"""
|
||||||
@@ -130,11 +189,16 @@ def get_process_cwd(pid: int) -> Optional[str]:
|
|||||||
informativo es la línea de comando completa, así que esta función es
|
informativo es la línea de comando completa, así que esta función es
|
||||||
secundaria.
|
secundaria.
|
||||||
"""
|
"""
|
||||||
out = _run_powershell(
|
if IS_WINDOWS:
|
||||||
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}' -ErrorAction SilentlyContinue).ExecutablePath"
|
out = _run_powershell(
|
||||||
)
|
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}' -ErrorAction SilentlyContinue).ExecutablePath"
|
||||||
if out:
|
)
|
||||||
return os.path.dirname(out)
|
if out:
|
||||||
|
return os.path.dirname(out)
|
||||||
|
return None
|
||||||
|
# POSIX: no hay un cwd fiable sin /proc (Linux) y macOS no lo expone vía ps.
|
||||||
|
# La línea de comando completa es la pista útil para nosotros, así que esta
|
||||||
|
# función queda como best-effort y devuelve None.
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -239,11 +303,40 @@ def stop_server(force: bool = False) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _kill_tree(pid: int) -> None:
|
def _kill_tree(pid: int) -> None:
|
||||||
subprocess.run(
|
if IS_WINDOWS:
|
||||||
["taskkill", "/F", "/T", "/PID", str(pid)],
|
subprocess.run(
|
||||||
stdout=subprocess.DEVNULL,
|
["taskkill", "/F", "/T", "/PID", str(pid)],
|
||||||
stderr=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
)
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
_kill_tree_posix(pid)
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_tree_posix(pid: int) -> None:
|
||||||
|
"""Mata recursivamente el proceso y sus hijos en macOS/Linux.
|
||||||
|
|
||||||
|
uvicorn arranca con `reload=True`, lo que crea un proceso reloader padre y un
|
||||||
|
worker hijo. El PID registrado en server_info.json es el del padre, así que
|
||||||
|
hay que descender por el árbol (`pgrep -P`) para no dejar al worker zombie
|
||||||
|
aferrado al puerto. Enviamos SIGTERM (apagado limpio); el padre propaga la
|
||||||
|
señal a uvicorn que cierra el socket ordenadamente.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
["pgrep", "-P", str(pid)], text=True, stderr=subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
children = [int(c) for c in out.split() if c.strip().isdigit()]
|
||||||
|
except Exception:
|
||||||
|
children = []
|
||||||
|
|
||||||
|
for child in children:
|
||||||
|
_kill_tree_posix(child)
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
except (ProcessLookupError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def wait_ready(timeout_sec: float = 30.0) -> int:
|
def wait_ready(timeout_sec: float = 30.0) -> int:
|
||||||
@@ -271,14 +364,14 @@ def preflight_check() -> int:
|
|||||||
if info and info.get("pid") and pid_is_alive(int(info["pid"])):
|
if info and info.get("pid") and pid_is_alive(int(info["pid"])):
|
||||||
port = info.get("port", 8000)
|
port = info.get("port", 8000)
|
||||||
print(f"[INFO] MP Manager ya esta corriendo en puerto {port} (PID {info['pid']}).")
|
print(f"[INFO] MP Manager ya esta corriendo en puerto {port} (PID {info['pid']}).")
|
||||||
print(f" Abre http://127.0.0.1:{port}/ o ejecuta stop.bat primero.")
|
print(f" Abre http://127.0.0.1:{port}/ o detén primero (stop.bat / stop.command).")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
port_pid = get_pid_listening_on(8000)
|
port_pid = get_pid_listening_on(8000)
|
||||||
if port_pid:
|
if port_pid:
|
||||||
if pid_belongs_to_mp_manager(port_pid):
|
if pid_belongs_to_mp_manager(port_pid):
|
||||||
print(f"[INFO] Hay un MP Manager huerfano en puerto 8000 (PID {port_pid}).")
|
print(f"[INFO] Hay un MP Manager huerfano en puerto 8000 (PID {port_pid}).")
|
||||||
print(f" Ejecuta stop.bat o usa: python runtime_control.py stop --force")
|
print(f" Detén (stop.bat / stop.command) o usa: python runtime_control.py stop --force")
|
||||||
return 1
|
return 1
|
||||||
cmd = get_process_cmdline(port_pid) or "(desconocido)"
|
cmd = get_process_cmdline(port_pid) or "(desconocido)"
|
||||||
print(f"[ADVERTENCIA] Puerto 8000 ocupado por OTRO proyecto (PID {port_pid}):")
|
print(f"[ADVERTENCIA] Puerto 8000 ocupado por OTRO proyecto (PID {port_pid}):")
|
||||||
|
|||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# setup_mac.command — Inicialización con UN CLICK de MP Manager en macOS/Linux.
|
||||||
|
#
|
||||||
|
# Equivalente "de arranque" que NO existe en Windows (allá se asume Python en el
|
||||||
|
# PATH). En macOS el Python del sistema suele ser 3.9 y está bloqueado para pip
|
||||||
|
# (PEP 668), así que aquí:
|
||||||
|
# 1. Detecta un Python 3.10+ del sistema.
|
||||||
|
# 2. Crea un entorno virtual aislado en .venv (no toca el Python del sistema).
|
||||||
|
# 3. Instala las dependencias de requirements.txt.
|
||||||
|
# 4. Instala el navegador Chromium para Playwright.
|
||||||
|
# 5. Crea el archivo .env a partir de .env.example si no existe.
|
||||||
|
#
|
||||||
|
# Es idempotente: puedes volver a ejecutarlo para actualizar dependencias.
|
||||||
|
# Doble clic en Finder para correrlo.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
set -euo pipefail
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/mp_common.sh"
|
||||||
|
|
||||||
|
banner "MP Manager - Configuración inicial (macOS / Linux)"
|
||||||
|
|
||||||
|
# --- 1. Buscar Python 3.10+ del sistema -----------------------------------
|
||||||
|
info "Buscando un Python ${PY_MIN_MAJOR}.${PY_MIN_MINOR}+ en el sistema..."
|
||||||
|
SYS_PY="$(find_system_python || true)"
|
||||||
|
if [ -z "$SYS_PY" ]; then
|
||||||
|
err "No se encontró Python ${PY_MIN_MAJOR}.${PY_MIN_MINOR} o superior."
|
||||||
|
echo
|
||||||
|
echo " Instálalo y reintenta. La forma más simple en macOS:"
|
||||||
|
echo " brew install python@3.12"
|
||||||
|
echo " (o descárgalo de https://www.python.org/downloads/macos/)"
|
||||||
|
hold_window
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "Python encontrado: $SYS_PY ($("$SYS_PY" --version 2>&1))"
|
||||||
|
|
||||||
|
# --- 2. Crear / reutilizar el entorno virtual ------------------------------
|
||||||
|
if [ -x "$VENV_PY" ]; then
|
||||||
|
info "Entorno virtual ya existe en .venv (se reutiliza)."
|
||||||
|
else
|
||||||
|
info "Creando entorno virtual en .venv ..."
|
||||||
|
"$SYS_PY" -m venv "$VENV_DIR"
|
||||||
|
ok "Entorno virtual creado."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 3. Instalar dependencias ---------------------------------------------
|
||||||
|
info "Actualizando pip ..."
|
||||||
|
"$VENV_PY" -m pip install --upgrade pip >/dev/null
|
||||||
|
|
||||||
|
info "Instalando dependencias de requirements.txt (puede tardar) ..."
|
||||||
|
"$VENV_PY" -m pip install -r "$PROJECT_DIR/requirements.txt"
|
||||||
|
ok "Dependencias de Python instaladas."
|
||||||
|
|
||||||
|
# --- 4. Navegador de Playwright -------------------------------------------
|
||||||
|
# Los scripts ghl_browser_*.py automatizan la UI web con Chromium. Sin esto
|
||||||
|
# fallarían al lanzar el navegador.
|
||||||
|
info "Instalando Chromium para Playwright ..."
|
||||||
|
if "$VENV_PY" -m playwright install chromium; then
|
||||||
|
ok "Chromium instalado."
|
||||||
|
else
|
||||||
|
warn "No se pudo instalar Chromium para Playwright."
|
||||||
|
warn "El dashboard funcionará; solo los scripts de navegador (workflows) fallarán."
|
||||||
|
warn "Puedes reintentar luego con: .venv/bin/python -m playwright install chromium"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 5. Archivo .env -------------------------------------------------------
|
||||||
|
if [ -f "$PROJECT_DIR/.env" ]; then
|
||||||
|
info "Archivo .env ya existe (no se sobrescribe)."
|
||||||
|
elif [ -f "$PROJECT_DIR/.env.example" ]; then
|
||||||
|
cp "$PROJECT_DIR/.env.example" "$PROJECT_DIR/.env"
|
||||||
|
ok "Creado .env a partir de .env.example — revísalo y completa tus credenciales."
|
||||||
|
else
|
||||||
|
warn "No hay .env.example; omito la creación de .env."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
banner "Configuración completada"
|
||||||
|
echo " Para arrancar el servidor: doble clic en start.command"
|
||||||
|
echo " Para detenerlo: doble clic en stop.command"
|
||||||
|
echo " Para reiniciarlo: doble clic en restart.command"
|
||||||
|
echo
|
||||||
|
echo " La primera vez, macOS puede pedir permiso para abrir un .command"
|
||||||
|
echo " descargado: clic derecho > Abrir, o Ajustes > Privacidad y seguridad."
|
||||||
|
hold_window
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# start.command — Equivalente macOS/Linux de start.bat.
|
||||||
|
# Arranca el servidor MP Manager en modo NORMAL.
|
||||||
|
# 1. Verifica que exista el venv (creado por setup_mac.command).
|
||||||
|
# 2. Pre-vuelo: rechaza relanzar si ya hay una instancia; avisa si el puerto
|
||||||
|
# 8000 lo tiene otro proyecto (main.py salta al siguiente libre).
|
||||||
|
# 3. Deja huella del modo (last_mode=normal) para que restart.command sepa
|
||||||
|
# cómo relanzar.
|
||||||
|
# 4. Lanza FastAPI en segundo plano (nohup) con la ruta ABSOLUTA a main.py
|
||||||
|
# —para que runtime_control pueda identificar el proceso— y registra logs.
|
||||||
|
# main.py escribe server_info.json con el puerto real y abre el navegador solo.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
set -euo pipefail
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/mp_common.sh"
|
||||||
|
|
||||||
|
banner "MP Manager - Iniciando Servidor Monte Providencia"
|
||||||
|
|
||||||
|
PY="$(require_venv)" || { hold_window; exit 1; }
|
||||||
|
|
||||||
|
# --- Pre-vuelo (misma lógica que Windows, vía runtime_control.py) ----------
|
||||||
|
set +e
|
||||||
|
"$PY" runtime_control.py preflight
|
||||||
|
PRE=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$PRE" -eq 1 ]; then
|
||||||
|
echo
|
||||||
|
info "No se relanza. Si quieres reiniciar usa restart.command."
|
||||||
|
hold_window
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$PRE" -eq 2 ]; then
|
||||||
|
echo
|
||||||
|
info "Continuando: MP Manager buscará el siguiente puerto libre."
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Huella de modo activo -------------------------------------------------
|
||||||
|
mkdir -p generated/runtime
|
||||||
|
echo "normal" > generated/runtime/last_mode
|
||||||
|
|
||||||
|
# --- Lanzar servidor en segundo plano --------------------------------------
|
||||||
|
mkdir -p generated/logs
|
||||||
|
LOG="$PROJECT_DIR/generated/logs/server.out"
|
||||||
|
info "Iniciando servidor FastAPI..."
|
||||||
|
nohup "$PY" "$PROJECT_DIR/main.py" >> "$LOG" 2>&1 &
|
||||||
|
disown || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==================================================="
|
||||||
|
echo " El servidor se está iniciando en segundo plano."
|
||||||
|
echo " El navegador se abrirá solo en el puerto correcto"
|
||||||
|
echo " (8000 o el siguiente libre)."
|
||||||
|
echo
|
||||||
|
echo " Logs: generated/logs/server.out"
|
||||||
|
echo " Para detener: stop.command"
|
||||||
|
echo " Reiniciar: restart.command"
|
||||||
|
echo "==================================================="
|
||||||
|
hold_window
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# start_persistent_profile.command — Equivalente macOS/Linux de
|
||||||
|
# start_persistent_profile.bat.
|
||||||
|
# Arranca el servidor con el PERFIL PERSISTENTE de Chrome para Playwright
|
||||||
|
# (más estable que la sesión shared). Todos los scripts ghl_browser_*.py usarán
|
||||||
|
# generated/browser/profile en lugar de generated/browser/session.json.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
set -euo pipefail
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/mp_common.sh"
|
||||||
|
|
||||||
|
banner "MP Manager - Modo Perfil Persistente Bucéfalo"
|
||||||
|
|
||||||
|
PY="$(require_venv)" || { hold_window; exit 1; }
|
||||||
|
|
||||||
|
# --- Pre-vuelo -------------------------------------------------------------
|
||||||
|
set +e
|
||||||
|
"$PY" runtime_control.py preflight
|
||||||
|
PRE=$?
|
||||||
|
set -e
|
||||||
|
if [ "$PRE" -eq 1 ]; then
|
||||||
|
echo
|
||||||
|
info "No se relanza. Usa restart.command para reiniciar el modo persistente."
|
||||||
|
hold_window
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Perfil persistente ----------------------------------------------------
|
||||||
|
export GHL_BROWSER_PROFILE_DIR="$PROJECT_DIR/generated/browser/profile"
|
||||||
|
info "GHL_BROWSER_PROFILE_DIR=$GHL_BROWSER_PROFILE_DIR"
|
||||||
|
|
||||||
|
if [ ! -d "$GHL_BROWSER_PROFILE_DIR" ]; then
|
||||||
|
echo
|
||||||
|
warn "El perfil aún no existe. La primera vez tendrás que generar la sesión:"
|
||||||
|
echo " 1. Ve a la pestaña Workflows y dale a \"Renovar sesión Bucéfalo\"."
|
||||||
|
echo " 2. Inicia sesión + MFA en la ventana del navegador."
|
||||||
|
echo " 3. La sesión se persistirá automáticamente en este directorio."
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Iniciando servidor FastAPI con perfil persistente activo..."
|
||||||
|
mkdir -p generated/runtime generated/logs
|
||||||
|
echo "persistent" > generated/runtime/last_mode
|
||||||
|
|
||||||
|
LOG="$PROJECT_DIR/generated/logs/server.out"
|
||||||
|
nohup "$PY" "$PROJECT_DIR/main.py" >> "$LOG" 2>&1 &
|
||||||
|
disown || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==================================================="
|
||||||
|
echo " Servidor iniciado en modo perfil persistente."
|
||||||
|
echo " Logs: generated/logs/server.out"
|
||||||
|
echo " Para detener: stop.command"
|
||||||
|
echo
|
||||||
|
echo " NOTA: en este modo no puedes correr dos scripts de"
|
||||||
|
echo " Playwright al mismo tiempo contra el mismo perfil."
|
||||||
|
echo "==================================================="
|
||||||
|
hold_window
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# stop.command — Equivalente macOS/Linux de stop.bat.
|
||||||
|
# Delega TODO a runtime_control.py para garantizar que solo se detiene MP
|
||||||
|
# Manager (nunca otro proyecto Python que escuche en el puerto 8000).
|
||||||
|
#
|
||||||
|
# Acepta los mismos flags que el .bat, p.ej.:
|
||||||
|
# ./stop.command --force (apaga también instancias huérfanas sin server_info)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
set -euo pipefail
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/mp_common.sh"
|
||||||
|
|
||||||
|
banner "MP Manager - Deteniendo Servidor Monte Providencia"
|
||||||
|
|
||||||
|
PY="$(require_venv)" || { hold_window; exit 1; }
|
||||||
|
|
||||||
|
set +e
|
||||||
|
"$PY" runtime_control.py stop "$@"
|
||||||
|
RC=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
hold_window
|
||||||
|
exit "$RC"
|
||||||
Reference in New Issue
Block a user