Convert dev launcher to Python
- Replace the shell convenience script with a cross-platform Python launcher. - Keep dev.sh as a Unix compatibility wrapper. - Let the direct backend bind with host and port overrides. - Update the root and webui README guidance for the new launcher. - Preserve the backend startup behavior used by the old dev flow.
This commit is contained in:
parent
fac4e1ba1a
commit
d8f8c6b95c
5 changed files with 367 additions and 235 deletions
|
|
@ -307,8 +307,8 @@ Run tests separately when needed:
|
||||||
python -m pytest
|
python -m pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
If you want a convenience launcher, `./dev.sh` starts both halves together.
|
If you want a convenience launcher, `python dev.py` starts both halves together
|
||||||
It is most useful on Linux, macOS, and WSL.
|
on any OS. `./dev.sh` remains available as a Unix shell wrapper.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
348
dev.py
Executable file
348
dev.py
Executable file
|
|
@ -0,0 +1,348 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""SoulSync development launcher.
|
||||||
|
|
||||||
|
Starts the backend and Vite dev server together, restarts the backend when
|
||||||
|
backend source files change, and handles shutdown cleanly across platforms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent
|
||||||
|
LOG_DIR = ROOT_DIR / 'logs'
|
||||||
|
GUNICORN_CONFIG = ROOT_DIR / 'gunicorn.dev.conf.py'
|
||||||
|
VITE_URL = os.environ.get('SOULSYNC_WEBUI_VITE_URL', 'http://127.0.0.1:5173').rstrip('/')
|
||||||
|
VITE_LOG_FILE = Path(os.environ.get('SOULSYNC_WEBUI_VITE_LOG', str(LOG_DIR / 'webui-vite.log')))
|
||||||
|
|
||||||
|
INCLUDED_SUFFIXES = {'.py', '.html', '.jinja', '.jinja2'}
|
||||||
|
SHUTDOWN_GRACE_SECONDS = int(os.environ.get('SOULSYNC_SHUTDOWN_GRACE_SECONDS', '10'))
|
||||||
|
FORCE_KILL_ON_SHUTDOWN = os.environ.get('SOULSYNC_FORCE_KILL_ON_SHUTDOWN', '1').lower() in {
|
||||||
|
'1',
|
||||||
|
'true',
|
||||||
|
'yes',
|
||||||
|
'on',
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown_requested = False
|
||||||
|
managed_processes: list[tuple[str, subprocess.Popen, object | None]] = []
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_command(*candidates: str) -> str | None:
|
||||||
|
for candidate in candidates:
|
||||||
|
resolved = shutil.which(candidate)
|
||||||
|
if resolved:
|
||||||
|
return resolved
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_excluded(path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
relative = path.relative_to(ROOT_DIR)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
parts = relative.parts
|
||||||
|
if not parts:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if any(part == '__pycache__' for part in parts):
|
||||||
|
return True
|
||||||
|
if parts[0] in {'.git', 'logs'}:
|
||||||
|
return True
|
||||||
|
if len(parts) >= 2 and parts[0] == 'webui' and parts[1] == 'node_modules':
|
||||||
|
return True
|
||||||
|
if len(parts) >= 3 and parts[0] == 'webui' and parts[1] == 'static' and parts[2] == 'dist':
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def build_backend_env(direct_mode: bool) -> dict[str, str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.setdefault('SOULSYNC_WEB_DEV_NO_CACHE', '1')
|
||||||
|
env.setdefault('SOULSYNC_WEBUI_VITE_DEV', '1')
|
||||||
|
env.setdefault('SOULSYNC_WEBUI_VITE_URL', VITE_URL)
|
||||||
|
env.setdefault('SOULSYNC_WEBUI_VITE_LOG', str(VITE_LOG_FILE))
|
||||||
|
env.setdefault('SOULSYNC_CONFIG_PATH', str(ROOT_DIR / 'config' / 'config.json'))
|
||||||
|
|
||||||
|
if direct_mode:
|
||||||
|
env.setdefault('SOULSYNC_WEB_BIND_HOST', '127.0.0.1')
|
||||||
|
env.setdefault('SOULSYNC_WEB_BIND_PORT', '8008')
|
||||||
|
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def start_process(label: str, cmd: list[str], *, log_file: Path | None = None, env: dict[str, str] | None = None) -> tuple[subprocess.Popen, object | None]:
|
||||||
|
log_handle = None
|
||||||
|
stdout = None
|
||||||
|
stderr = None
|
||||||
|
if log_file is not None:
|
||||||
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_handle = log_file.open('ab')
|
||||||
|
stdout = log_handle
|
||||||
|
stderr = log_handle
|
||||||
|
|
||||||
|
creationflags = 0
|
||||||
|
start_new_session = False
|
||||||
|
if os.name == 'nt':
|
||||||
|
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
|
else:
|
||||||
|
start_new_session = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
cwd=str(ROOT_DIR),
|
||||||
|
env=env,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=stdout,
|
||||||
|
stderr=stderr,
|
||||||
|
creationflags=creationflags,
|
||||||
|
start_new_session=start_new_session,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
if log_handle is not None:
|
||||||
|
log_handle.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
managed_processes.append((label, proc, log_handle))
|
||||||
|
return proc, log_handle
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_exit(proc: subprocess.Popen, seconds: int) -> bool:
|
||||||
|
checks = max(1, int(seconds * 10))
|
||||||
|
for _ in range(checks):
|
||||||
|
if proc.poll() is not None:
|
||||||
|
return True
|
||||||
|
time.sleep(0.1)
|
||||||
|
return proc.poll() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def stop_process(label: str, proc: subprocess.Popen, log_handle: object | None) -> None:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
if log_handle is not None:
|
||||||
|
log_handle.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f'Stopping {label}...')
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.name == 'nt':
|
||||||
|
proc.terminate()
|
||||||
|
else:
|
||||||
|
os.killpg(proc.pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not wait_for_exit(proc, SHUTDOWN_GRACE_SECONDS):
|
||||||
|
if not FORCE_KILL_ON_SHUTDOWN:
|
||||||
|
print(f'{label} did not exit in time; skipping forced kill for this test run.')
|
||||||
|
else:
|
||||||
|
print(f'{label} did not exit in time; forcing shutdown...')
|
||||||
|
if os.name == 'nt':
|
||||||
|
subprocess.run(
|
||||||
|
['taskkill', '/T', '/F', '/PID', str(proc.pid)],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.killpg(proc.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
wait_for_exit(proc, 5)
|
||||||
|
|
||||||
|
if log_handle is not None:
|
||||||
|
log_handle.close()
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup() -> None:
|
||||||
|
global shutdown_requested
|
||||||
|
|
||||||
|
if shutdown_requested:
|
||||||
|
return
|
||||||
|
shutdown_requested = True
|
||||||
|
|
||||||
|
for label, proc, log_handle in reversed(managed_processes):
|
||||||
|
stop_process(label, proc, log_handle)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_backend_watch_state() -> str:
|
||||||
|
rows: list[str] = []
|
||||||
|
|
||||||
|
for dirpath, dirnames, filenames in os.walk(ROOT_DIR):
|
||||||
|
current_dir = Path(dirpath)
|
||||||
|
if is_excluded(current_dir):
|
||||||
|
dirnames[:] = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
dirnames[:] = [
|
||||||
|
name
|
||||||
|
for name in dirnames
|
||||||
|
if not is_excluded(current_dir / name) and name != '__pycache__'
|
||||||
|
]
|
||||||
|
|
||||||
|
for filename in filenames:
|
||||||
|
path = current_dir / filename
|
||||||
|
if path.suffix not in INCLUDED_SUFFIXES:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
stat = path.stat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
rows.append(f'{stat.st_mtime_ns} {path}')
|
||||||
|
|
||||||
|
return '\n'.join(sorted(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def start_vite() -> subprocess.Popen:
|
||||||
|
npm = resolve_command('npm', 'npm.cmd')
|
||||||
|
if npm is None:
|
||||||
|
raise SystemExit('npm is required to run the Vite dev server.')
|
||||||
|
|
||||||
|
print(f'Starting Vite dev server at {VITE_URL}...')
|
||||||
|
vite_cmd = [
|
||||||
|
npm,
|
||||||
|
'--prefix',
|
||||||
|
str(ROOT_DIR / 'webui'),
|
||||||
|
'run',
|
||||||
|
'dev',
|
||||||
|
'--',
|
||||||
|
'--host',
|
||||||
|
'127.0.0.1',
|
||||||
|
'--port',
|
||||||
|
'5173',
|
||||||
|
]
|
||||||
|
proc, _ = start_process('Vite dev server', vite_cmd, log_file=VITE_LOG_FILE, env=os.environ.copy())
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_vite_ready(vite_proc: subprocess.Popen) -> None:
|
||||||
|
ready_url = f'{VITE_URL}/static/dist/@vite/client'
|
||||||
|
vite_ready = False
|
||||||
|
|
||||||
|
for _ in range(50):
|
||||||
|
if vite_proc.poll() is not None:
|
||||||
|
print('Warning: Vite dev server exited before it became ready.')
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(ready_url, timeout=1) as response:
|
||||||
|
if response.status < 400:
|
||||||
|
vite_ready = True
|
||||||
|
break
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError):
|
||||||
|
pass
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
if vite_ready:
|
||||||
|
print('Vite dev server is ready.')
|
||||||
|
else:
|
||||||
|
print('Warning: timed out waiting for the Vite dev server.')
|
||||||
|
print('The backend will still start, but the frontend may not hot-reload yet.')
|
||||||
|
|
||||||
|
|
||||||
|
def start_backend() -> tuple[subprocess.Popen, object | None]:
|
||||||
|
backend_mode = os.environ.get('SOULSYNC_DEV_BACKEND', '').strip().lower()
|
||||||
|
direct_mode = backend_mode == 'direct'
|
||||||
|
gunicorn_mode = backend_mode == 'gunicorn'
|
||||||
|
|
||||||
|
if not backend_mode:
|
||||||
|
if os.name == 'nt':
|
||||||
|
direct_mode = True
|
||||||
|
elif resolve_command('gunicorn') is None:
|
||||||
|
print('gunicorn not found; falling back to direct Python server.')
|
||||||
|
direct_mode = True
|
||||||
|
else:
|
||||||
|
gunicorn_mode = True
|
||||||
|
|
||||||
|
print('Starting SoulSync web server...')
|
||||||
|
|
||||||
|
if gunicorn_mode:
|
||||||
|
gunicorn = resolve_command('gunicorn')
|
||||||
|
if gunicorn is None:
|
||||||
|
raise SystemExit('gunicorn is not available but SOULSYNC_DEV_BACKEND=gunicorn was requested.')
|
||||||
|
print(f'Using Gunicorn config: {GUNICORN_CONFIG}')
|
||||||
|
cmd = [gunicorn, '-c', str(GUNICORN_CONFIG), 'wsgi:application']
|
||||||
|
else:
|
||||||
|
print('Using direct Python server for backend.')
|
||||||
|
cmd = [sys.executable, str(ROOT_DIR / 'web_server.py')]
|
||||||
|
|
||||||
|
proc, log_handle = start_process(
|
||||||
|
'SoulSync web server',
|
||||||
|
cmd,
|
||||||
|
env=build_backend_env(direct_mode),
|
||||||
|
)
|
||||||
|
return proc, log_handle
|
||||||
|
|
||||||
|
|
||||||
|
def watch_and_run_backend() -> None:
|
||||||
|
last_state = compute_backend_watch_state()
|
||||||
|
backend_proc, backend_log = start_backend()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not shutdown_requested:
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
if backend_proc.poll() is not None:
|
||||||
|
print('SoulSync web server exited. Restarting...')
|
||||||
|
stop_process('SoulSync web server', backend_proc, backend_log)
|
||||||
|
managed_processes.pop()
|
||||||
|
backend_proc, backend_log = start_backend()
|
||||||
|
last_state = compute_backend_watch_state()
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_state = compute_backend_watch_state()
|
||||||
|
if current_state != last_state:
|
||||||
|
print('Detected backend file changes. Restarting SoulSync web server...')
|
||||||
|
last_state = current_state
|
||||||
|
stop_process('SoulSync web server', backend_proc, backend_log)
|
||||||
|
managed_processes.pop()
|
||||||
|
backend_proc, backend_log = start_backend()
|
||||||
|
finally:
|
||||||
|
if backend_proc.poll() is None:
|
||||||
|
stop_process('SoulSync web server', backend_proc, backend_log)
|
||||||
|
if managed_processes:
|
||||||
|
managed_processes.pop()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not (ROOT_DIR / 'webui' / 'node_modules').is_dir():
|
||||||
|
print('webui/node_modules is missing.')
|
||||||
|
print('Run: cd webui && npm install')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
vite_proc = start_vite()
|
||||||
|
try:
|
||||||
|
wait_for_vite_ready(vite_proc)
|
||||||
|
print(f'Vite log: {VITE_LOG_FILE}')
|
||||||
|
print('Backend file watching is enabled.')
|
||||||
|
watch_and_run_backend()
|
||||||
|
finally:
|
||||||
|
cleanup()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_signal(signum: int, _frame) -> None:
|
||||||
|
raise SystemExit(130 if signum == signal.SIGINT else 143)
|
||||||
|
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, _handle_signal)
|
||||||
|
if hasattr(signal, 'SIGTERM'):
|
||||||
|
signal.signal(signal.SIGTERM, _handle_signal)
|
||||||
|
if hasattr(signal, 'SIGBREAK'):
|
||||||
|
signal.signal(signal.SIGBREAK, _handle_signal)
|
||||||
|
|
||||||
|
atexit.register(cleanup)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
239
dev.sh
239
dev.sh
|
|
@ -1,235 +1,16 @@
|
||||||
#!/bin/bash
|
#!/bin/sh
|
||||||
# SoulSync Development Launcher Script
|
# Compatibility wrapper for the Python launcher.
|
||||||
# Starts the Python backend and Vite dev server together for local work.
|
set -e
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
mkdir -p "$SCRIPT_DIR/logs"
|
|
||||||
|
|
||||||
DEV_GUNICORN_CONFIG="$SCRIPT_DIR/gunicorn.dev.conf.py"
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
GUNICORN_CONFIG="$DEV_GUNICORN_CONFIG"
|
exec python3 "$SCRIPT_DIR/dev.py" "$@"
|
||||||
|
|
||||||
VITE_URL="${SOULSYNC_WEBUI_VITE_URL:-http://127.0.0.1:5173}"
|
|
||||||
VITE_LOG_FILE="${SOULSYNC_WEBUI_VITE_LOG:-$SCRIPT_DIR/logs/webui-vite.log}"
|
|
||||||
|
|
||||||
VITE_PID=""
|
|
||||||
SERVER_PID=""
|
|
||||||
SHUTTING_DOWN="0"
|
|
||||||
SHUTDOWN_GRACE_SECONDS="${SOULSYNC_SHUTDOWN_GRACE_SECONDS:-10}"
|
|
||||||
FORCE_KILL_ON_SHUTDOWN="${SOULSYNC_FORCE_KILL_ON_SHUTDOWN:-1}"
|
|
||||||
|
|
||||||
stop_process_group() {
|
|
||||||
local pid="$1"
|
|
||||||
local label="$2"
|
|
||||||
|
|
||||||
if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Stopping ${label}..."
|
|
||||||
kill -TERM -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
|
|
||||||
|
|
||||||
local max_checks=$((SHUTDOWN_GRACE_SECONDS * 10))
|
|
||||||
for _ in $(seq 1 "$max_checks"); do
|
|
||||||
if ! kill -0 "$pid" 2>/dev/null; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
|
|
||||||
if kill -0 "$pid" 2>/dev/null; then
|
|
||||||
if [[ "$FORCE_KILL_ON_SHUTDOWN" != "1" ]]; then
|
|
||||||
echo "${label} did not exit in time; skipping forced kill for this test run."
|
|
||||||
wait "$pid" 2>/dev/null || true
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
echo "${label} did not exit in time; forcing shutdown..."
|
|
||||||
kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
wait "$pid" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
if [[ "$SHUTTING_DOWN" == "1" ]]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
SHUTTING_DOWN="1"
|
|
||||||
|
|
||||||
stop_process_group "${SERVER_PID}" "SoulSync web server"
|
|
||||||
stop_process_group "${VITE_PID}" "Vite dev server"
|
|
||||||
}
|
|
||||||
|
|
||||||
trap cleanup EXIT INT TERM
|
|
||||||
|
|
||||||
start_in_own_session() {
|
|
||||||
local pid_file="$1"
|
|
||||||
shift
|
|
||||||
|
|
||||||
local log_file=""
|
|
||||||
if [[ "${1:-}" == "--log-file" ]]; then
|
|
||||||
log_file="$2"
|
|
||||||
shift 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 - "$pid_file" "$log_file" "$@" <<'PY'
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
pid_file = sys.argv[1]
|
|
||||||
log_file = sys.argv[2]
|
|
||||||
cmd = sys.argv[3:]
|
|
||||||
stdout = None
|
|
||||||
stderr = None
|
|
||||||
log_handle = None
|
|
||||||
|
|
||||||
if log_file:
|
|
||||||
log_handle = open(log_file, "ab")
|
|
||||||
stdout = log_handle
|
|
||||||
stderr = log_handle
|
|
||||||
|
|
||||||
try:
|
|
||||||
process = subprocess.Popen(cmd, start_new_session=True, stdout=stdout, stderr=stderr)
|
|
||||||
with open(pid_file, "w", encoding="utf-8") as pid_handle:
|
|
||||||
pid_handle.write(str(process.pid))
|
|
||||||
finally:
|
|
||||||
if log_handle is not None:
|
|
||||||
log_handle.close()
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
start_server() {
|
|
||||||
echo "Starting SoulSync web server..."
|
|
||||||
echo "Using Gunicorn config: ${GUNICORN_CONFIG}"
|
|
||||||
local pid_file
|
|
||||||
pid_file="$(mktemp "$SCRIPT_DIR/logs/.gunicorn-pid.XXXXXX")"
|
|
||||||
start_in_own_session "$pid_file" gunicorn -c "${GUNICORN_CONFIG}" wsgi:application
|
|
||||||
SERVER_PID="$(<"$pid_file")"
|
|
||||||
rm -f "$pid_file"
|
|
||||||
}
|
|
||||||
|
|
||||||
stop_server() {
|
|
||||||
stop_process_group "${SERVER_PID}" "SoulSync web server"
|
|
||||||
SERVER_PID=""
|
|
||||||
}
|
|
||||||
|
|
||||||
compute_backend_watch_state() {
|
|
||||||
python3 - "$SCRIPT_DIR" <<'PY'
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
root = Path(sys.argv[1]).resolve()
|
|
||||||
excluded_dirs = {
|
|
||||||
root / '.git',
|
|
||||||
root / 'logs',
|
|
||||||
root / 'webui' / 'node_modules',
|
|
||||||
root / 'webui' / 'static' / 'dist',
|
|
||||||
}
|
|
||||||
included_suffixes = {'.py', '.html', '.jinja', '.jinja2'}
|
|
||||||
rows = []
|
|
||||||
|
|
||||||
for dirpath, dirnames, filenames in os.walk(root):
|
|
||||||
current_dir = Path(dirpath)
|
|
||||||
if current_dir in excluded_dirs:
|
|
||||||
dirnames[:] = []
|
|
||||||
continue
|
|
||||||
if any(part == '__pycache__' for part in current_dir.parts):
|
|
||||||
dirnames[:] = []
|
|
||||||
continue
|
|
||||||
|
|
||||||
dirnames[:] = [
|
|
||||||
name
|
|
||||||
for name in dirnames
|
|
||||||
if (current_dir / name) not in excluded_dirs and name != '__pycache__'
|
|
||||||
]
|
|
||||||
|
|
||||||
for filename in filenames:
|
|
||||||
path = current_dir / filename
|
|
||||||
if any(part == '__pycache__' for part in path.parts):
|
|
||||||
continue
|
|
||||||
if path.suffix not in included_suffixes:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
stat = path.stat()
|
|
||||||
except FileNotFoundError:
|
|
||||||
continue
|
|
||||||
rows.append(f'{stat.st_mtime_ns} {path}')
|
|
||||||
|
|
||||||
for row in sorted(rows):
|
|
||||||
print(row)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
watch_and_run_server() {
|
|
||||||
local last_state=""
|
|
||||||
local current_state=""
|
|
||||||
|
|
||||||
last_state="$(compute_backend_watch_state)"
|
|
||||||
start_server
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
if [[ "$SHUTTING_DOWN" == "1" ]]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${SERVER_PID}" ]] && ! kill -0 "${SERVER_PID}" 2>/dev/null; then
|
|
||||||
echo "SoulSync web server exited. Restarting..."
|
|
||||||
start_server
|
|
||||||
last_state="$(compute_backend_watch_state)"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
current_state="$(compute_backend_watch_state)"
|
|
||||||
if [[ "$current_state" != "$last_state" ]]; then
|
|
||||||
echo "Detected backend file changes. Restarting SoulSync web server..."
|
|
||||||
last_state="$current_state"
|
|
||||||
stop_server
|
|
||||||
start_server
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ ! -d "$SCRIPT_DIR/webui/node_modules" ]]; then
|
|
||||||
echo "webui/node_modules is missing."
|
|
||||||
echo "Run: cd webui && npm install"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting Vite dev server at ${VITE_URL}..."
|
if command -v python >/dev/null 2>&1; then
|
||||||
mkdir -p "$(dirname "$VITE_LOG_FILE")"
|
exec python "$SCRIPT_DIR/dev.py" "$@"
|
||||||
VITE_PID_FILE="$(mktemp "$SCRIPT_DIR/logs/.vite-pid.XXXXXX")"
|
|
||||||
start_in_own_session "$VITE_PID_FILE" --log-file "$VITE_LOG_FILE" npm --prefix "$SCRIPT_DIR/webui" run dev -- --host 127.0.0.1 --port 5173
|
|
||||||
VITE_PID="$(<"$VITE_PID_FILE")"
|
|
||||||
rm -f "$VITE_PID_FILE"
|
|
||||||
|
|
||||||
if command -v curl >/dev/null 2>&1; then
|
|
||||||
READY_URL="${VITE_URL}/static/dist/@vite/client"
|
|
||||||
vite_ready="0"
|
|
||||||
for _ in {1..50}; do
|
|
||||||
if ! kill -0 "${VITE_PID}" 2>/dev/null; then
|
|
||||||
echo "Warning: Vite dev server exited before it became ready."
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if curl -fsS "$READY_URL" >/dev/null 2>&1; then
|
|
||||||
vite_ready="1"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 0.2
|
|
||||||
done
|
|
||||||
if [[ "$vite_ready" == "1" ]]; then
|
|
||||||
echo "Vite dev server is ready."
|
|
||||||
else
|
|
||||||
echo "Warning: timed out waiting for the Vite dev server."
|
|
||||||
echo "The backend will still start, but the frontend may not hot-reload yet."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
sleep 2
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Vite log: $VITE_LOG_FILE"
|
echo "Python is required to run the SoulSync dev launcher."
|
||||||
echo "Backend file watching is enabled."
|
exit 1
|
||||||
watch_and_run_server
|
|
||||||
|
|
|
||||||
|
|
@ -35896,8 +35896,11 @@ def start_runtime_services():
|
||||||
# Direct execution: python web_server.py (dev/Windows fallback)
|
# Direct execution: python web_server.py (dev/Windows fallback)
|
||||||
# Production should use: gunicorn -c gunicorn.conf.py wsgi:application
|
# Production should use: gunicorn -c gunicorn.conf.py wsgi:application
|
||||||
if _DIRECT_RUN:
|
if _DIRECT_RUN:
|
||||||
|
web_run_host = os.environ.get('SOULSYNC_WEB_BIND_HOST', '0.0.0.0')
|
||||||
|
web_run_port = int(os.environ.get('SOULSYNC_WEB_BIND_PORT', '8008'))
|
||||||
|
display_host = '127.0.0.1' if web_run_host in {'0.0.0.0', '::'} else web_run_host
|
||||||
logger.info("Starting SoulSync Web UI Server...")
|
logger.info("Starting SoulSync Web UI Server...")
|
||||||
logger.info("Open your browser and navigate to http://127.0.0.1:8008")
|
logger.info(f"Open your browser and navigate to http://{display_host}:{web_run_port}")
|
||||||
logger.info("Tip: For production, use gunicorn -c gunicorn.conf.py wsgi:application")
|
logger.info("Tip: For production, use gunicorn -c gunicorn.conf.py wsgi:application")
|
||||||
start_runtime_services()
|
start_runtime_services()
|
||||||
socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True)
|
socketio.run(app, host=web_run_host, port=web_run_port, debug=False, allow_unsafe_werkzeug=True)
|
||||||
|
|
|
||||||
|
|
@ -89,5 +89,5 @@ npm run check
|
||||||
npm run fix
|
npm run fix
|
||||||
```
|
```
|
||||||
|
|
||||||
If you want a convenience wrapper, the repo root also includes `./dev.sh`.
|
If you want a convenience launcher, the repo root includes `python dev.py`
|
||||||
It starts both halves together and is most useful on Linux, macOS, and WSL.
|
for any OS and `./dev.sh` as a Unix shell wrapper.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue