175 lines
5.9 KiB
Python
175 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""dev_watch.py — Watch creator sources and hot-sync into a running QwenPaw.
|
|
|
|
Frontend: expects `vite build --watch` (started by dev.sh) to keep
|
|
rebuilding ui/dist incrementally; any dist change is rsynced into the
|
|
installed plugin copy. Plugin static files are read from disk on every
|
|
request, so a browser hard-refresh is all that's needed — no reinstall.
|
|
|
|
Backend: any backend/*.py change is rsynced into the installed copy and
|
|
then hot-reloaded via `POST /api/plugins/install` with source pointing at
|
|
the installed dir itself. The loader skips copytree when source == target,
|
|
so this is a pure module reload (~1s), using the localhost auth bypass.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
|
|
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
WORKING_DIR = os.path.expanduser(
|
|
os.environ.get("QWENPAW_WORKING_DIR", "~/.qwenpaw"),
|
|
)
|
|
PORT = os.environ.get("QWENPAW_PORT", "8088")
|
|
INSTALLED_DIR = os.path.join(WORKING_DIR, "plugins", "qwenpaw-creator")
|
|
POLL_SECONDS = 1.0
|
|
|
|
# Watch only vite's outDir (dist/app). dist/index.js is NOT watched or
|
|
# written here: vite watch ignores only its outDir, so writing anything
|
|
# else under ui/ (e.g. via package-plugin.mjs) retriggers a rebuild and
|
|
# creates an endless build loop. The entry is generated in-memory and
|
|
# written straight into the installed copy instead.
|
|
FRONTEND_SRC = os.path.join(PLUGIN_DIR, "ui", "dist", "app")
|
|
BACKEND_SRC = os.path.join(PLUGIN_DIR, "backend")
|
|
|
|
|
|
def snapshot(root: str, exts: tuple = ()) -> dict:
|
|
result = {}
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
|
for name in filenames:
|
|
if exts and not name.endswith(exts):
|
|
continue
|
|
path = os.path.join(dirpath, name)
|
|
try:
|
|
result[path] = os.stat(path).st_mtime
|
|
except OSError:
|
|
pass
|
|
return result
|
|
|
|
|
|
def rsync(src: str, dst: str) -> None:
|
|
subprocess.run(
|
|
[
|
|
"rsync",
|
|
"-a",
|
|
"--delete",
|
|
"--exclude",
|
|
"__pycache__",
|
|
src.rstrip("/") + "/",
|
|
dst.rstrip("/") + "/",
|
|
],
|
|
check=True,
|
|
)
|
|
|
|
|
|
def sync_frontend() -> None:
|
|
"""Mirror dist/app into the installed copy and regenerate index.js.
|
|
|
|
Replicates ui/scripts/package-plugin.mjs (build-id substitution into
|
|
plugin-entry.js) without touching the workspace, to avoid retriggering
|
|
vite watch.
|
|
"""
|
|
index_html = os.path.join(FRONTEND_SRC, "index.html")
|
|
with open(index_html, "rb") as f:
|
|
build_id = hashlib.sha256(f.read()).hexdigest()[:12]
|
|
with open(
|
|
os.path.join(PLUGIN_DIR, "ui", "plugin-entry.js"),
|
|
encoding="utf-8",
|
|
) as f:
|
|
entry = f.read()
|
|
if "__CREATOR_BUILD_ID__" not in entry:
|
|
raise RuntimeError(
|
|
"plugin-entry.js missing __CREATOR_BUILD_ID__ placeholder",
|
|
)
|
|
dst_dist = os.path.join(INSTALLED_DIR, "ui", "dist")
|
|
rsync(FRONTEND_SRC, os.path.join(dst_dist, "app"))
|
|
with open(os.path.join(dst_dist, "index.js"), "w", encoding="utf-8") as f:
|
|
f.write(entry.replace("__CREATOR_BUILD_ID__", build_id))
|
|
|
|
|
|
def reload_backend() -> None:
|
|
payload = json.dumps({"source": INSTALLED_DIR, "force": True}).encode()
|
|
req = urllib.request.Request(
|
|
f"http://127.0.0.1:{PORT}/api/plugins/install",
|
|
data=payload,
|
|
method="POST",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
json.loads(resp.read())
|
|
|
|
|
|
def main() -> None:
|
|
if not os.path.isdir(INSTALLED_DIR):
|
|
sys.exit(
|
|
f"Installed copy not found: {INSTALLED_DIR}\n"
|
|
"Run scripts/dev.sh install once first.",
|
|
)
|
|
print(f"[dev-watch] installed copy : {INSTALLED_DIR}")
|
|
print(
|
|
"[dev-watch] backend reload : "
|
|
f"POST 127.0.0.1:{PORT}/api/plugins/install (force)",
|
|
)
|
|
print("[dev-watch] watching ui/dist/app and backend/ ... (Ctrl+C to stop)")
|
|
|
|
fe_state = snapshot(FRONTEND_SRC)
|
|
be_state = snapshot(BACKEND_SRC, (".py", ".json", ".txt"))
|
|
|
|
while True:
|
|
time.sleep(POLL_SECONDS)
|
|
|
|
fe_now = snapshot(FRONTEND_SRC)
|
|
if fe_now != fe_state:
|
|
# Debounce: wait until the vite rebuild fully settles (snapshot
|
|
# stable across two polls) before syncing.
|
|
while True:
|
|
time.sleep(0.7)
|
|
settled = snapshot(FRONTEND_SRC)
|
|
if settled == fe_now:
|
|
break
|
|
fe_now = settled
|
|
try:
|
|
sync_frontend()
|
|
fe_state = fe_now
|
|
print(
|
|
f"[dev-watch] {time.strftime('%H:%M:%S')} "
|
|
"frontend synced — hard-refresh the browser",
|
|
)
|
|
except Exception as exc:
|
|
# Keep fe_state unchanged so the next poll retries (e.g. a
|
|
# sync attempted mid-build before index.html existed).
|
|
print(
|
|
f"[dev-watch] frontend sync failed, retrying: {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
time.sleep(2)
|
|
|
|
be_now = snapshot(BACKEND_SRC, (".py", ".json", ".txt"))
|
|
if be_now != be_state:
|
|
rsync(BACKEND_SRC, os.path.join(INSTALLED_DIR, "backend"))
|
|
be_state = be_now
|
|
try:
|
|
reload_backend()
|
|
print(
|
|
f"[dev-watch] {time.strftime('%H:%M:%S')} "
|
|
"backend synced + hot-reloaded",
|
|
)
|
|
except Exception as exc:
|
|
print(
|
|
f"[dev-watch] backend reload FAILED: {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except KeyboardInterrupt:
|
|
print("\n[dev-watch] stopped")
|