feat: auto-install sshx on plugin load + defer dashboard restart + safe JSON parsing
- __init__.py: auto-install sshx in background thread on register() - plugin_api.py: startup event auto-installs sshx, defers dashboard restart via BackgroundTasks - plugin_api.py: _ensure_sshx returns (success, needs_restart) tuple - plugin_api.py: /status returns 'installing' state while auto-install runs - index.js: safeFetchJSON wraps fetch+JSON.parse with error handling - index.js: shows 'Installing sshx...' with polling when status=installing - index.js: checks install status before calling /start
This commit is contained in:
parent
d25b984eac
commit
ffece76498
20
__init__.py
20
__init__.py
|
|
@ -88,8 +88,26 @@ def _run_sshx(tool_ctx):
|
||||||
threading.Thread(target=_reader, daemon=True).start()
|
threading.Thread(target=_reader, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_install_sshx():
|
||||||
|
if shutil.which("sshx"):
|
||||||
|
logger.info("sshx already installed at %s", shutil.which("sshx"))
|
||||||
|
return
|
||||||
|
logger.info("Auto-installing sshx...")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
|
||||||
|
capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and shutil.which("sshx"):
|
||||||
|
logger.info("sshx installed successfully")
|
||||||
|
else:
|
||||||
|
logger.error("sshx install failed: %s", result.stderr)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("sshx auto-install error: %s", e)
|
||||||
|
|
||||||
|
|
||||||
def register(ctx):
|
def register(ctx):
|
||||||
"""Register the sshx_start tool."""
|
threading.Thread(target=_auto_install_sshx, daemon=True).start()
|
||||||
ctx.register_tool(
|
ctx.register_tool(
|
||||||
name="sshx_start",
|
name="sshx_start",
|
||||||
toolset="sshx-link",
|
toolset="sshx-link",
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,16 @@
|
||||||
|
|
||||||
const fetchJSON = SDK.fetchJSON;
|
const fetchJSON = SDK.fetchJSON;
|
||||||
|
|
||||||
|
async function safeFetchJSON(path, opts) {
|
||||||
|
const res = await fetch(path, opts || {});
|
||||||
|
const text = await res.text();
|
||||||
|
try { return JSON.parse(text); } catch (_) {
|
||||||
|
throw new Error("Server returned non-JSON (status " + res.status + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function apiPost(path) {
|
async function apiPost(path) {
|
||||||
const res = await fetchJSON(path, { method: "POST" });
|
const res = await safeFetchJSON(path, { method: "POST" });
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,7 +30,12 @@
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetchJSON("/api/plugins/sshx-link/start");
|
var res = await safeFetchJSON("/api/plugins/sshx-link/status");
|
||||||
|
if (res.status === "installing") {
|
||||||
|
setStatus("installing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res = await safeFetchJSON("/api/plugins/sshx-link/start");
|
||||||
if (res.link) {
|
if (res.link) {
|
||||||
setLink(res.link);
|
setLink(res.link);
|
||||||
setStatus("running");
|
setStatus("running");
|
||||||
|
|
@ -38,7 +51,7 @@
|
||||||
|
|
||||||
const stopSSHX = useCallback(async function () {
|
const stopSSHX = useCallback(async function () {
|
||||||
try {
|
try {
|
||||||
await apiPost("/api/plugins/sshx-link/stop");
|
await safeFetchJSON("/api/plugins/sshx-link/stop", { method: "POST" });
|
||||||
setLink(null);
|
setLink(null);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -48,10 +61,12 @@
|
||||||
|
|
||||||
const checkStatus = useCallback(async function () {
|
const checkStatus = useCallback(async function () {
|
||||||
try {
|
try {
|
||||||
const res = await fetchJSON("/api/plugins/sshx-link/status");
|
const res = await safeFetchJSON("/api/plugins/sshx-link/status");
|
||||||
if (res.status === "running" && res.link) {
|
if (res.status === "running" && res.link) {
|
||||||
setLink(res.link);
|
setLink(res.link);
|
||||||
setStatus("running");
|
setStatus("running");
|
||||||
|
} else if (res.status === "installing") {
|
||||||
|
setStatus("installing");
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -60,6 +75,28 @@
|
||||||
checkStatus();
|
checkStatus();
|
||||||
}, [checkStatus]);
|
}, [checkStatus]);
|
||||||
|
|
||||||
|
useEffect(function () {
|
||||||
|
if (status !== "installing") return;
|
||||||
|
var timer = setInterval(function () {
|
||||||
|
checkStatus();
|
||||||
|
}, 2000);
|
||||||
|
return function () { clearInterval(timer); };
|
||||||
|
}, [status, checkStatus]);
|
||||||
|
|
||||||
|
if (status === "installing") {
|
||||||
|
return React.createElement(Card, null,
|
||||||
|
React.createElement(CardHeader, null,
|
||||||
|
React.createElement(CardTitle, null, "SSHX Shared Terminal")
|
||||||
|
),
|
||||||
|
React.createElement(CardContent, null,
|
||||||
|
React.createElement("p", {
|
||||||
|
className: "text-sm text-yellow-500",
|
||||||
|
style: { fontWeight: 600 }
|
||||||
|
}, "Installing sshx...")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (status === "running" && link) {
|
if (status === "running" && link) {
|
||||||
return React.createElement(Card, null,
|
return React.createElement(Card, null,
|
||||||
React.createElement(CardHeader, null,
|
React.createElement(CardHeader, null,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import shutil
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, BackgroundTasks
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -14,28 +14,58 @@ router = APIRouter()
|
||||||
|
|
||||||
_sshx_link = None
|
_sshx_link = None
|
||||||
_sshx_pid = None
|
_sshx_pid = None
|
||||||
|
_sshx_installing = False
|
||||||
_sshx_log = "/tmp/sshx_link.log"
|
_sshx_log = "/tmp/sshx_link.log"
|
||||||
_sshx_pid_file = "/tmp/sshx_link.pid"
|
_sshx_pid_file = "/tmp/sshx_link.pid"
|
||||||
|
|
||||||
LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9_-]+(?:#[A-Za-z0-9_-]+)?")
|
LINK_RE = re.compile(r"https://sshx\.io/s/[A-Za-z0-9_-]+(?:#[A-Za-z0-9_-]+)?")
|
||||||
|
|
||||||
|
import threading as _threading
|
||||||
|
|
||||||
def _ensure_sshx() -> bool:
|
|
||||||
already = shutil.which("sshx") is not None
|
def _auto_install():
|
||||||
if already:
|
global _sshx_installing
|
||||||
return True
|
if shutil.which("sshx"):
|
||||||
|
logger.info("sshx already installed at %s", shutil.which("sshx"))
|
||||||
|
return
|
||||||
|
_sshx_installing = True
|
||||||
|
logger.info("Auto-installing sshx...")
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
|
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
|
||||||
capture_output=True, timeout=60,
|
capture_output=True, timeout=60,
|
||||||
)
|
)
|
||||||
if shutil.which("sshx"):
|
if shutil.which("sshx"):
|
||||||
|
logger.info("sshx installed successfully")
|
||||||
_trigger_dashboard_restart()
|
_trigger_dashboard_restart()
|
||||||
return True
|
else:
|
||||||
return False
|
logger.error("sshx install failed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("sshx auto-install error: %s", e)
|
||||||
|
finally:
|
||||||
|
_sshx_installing = False
|
||||||
|
|
||||||
|
|
||||||
|
@router.on_event("startup")
|
||||||
|
def _startup_auto_install():
|
||||||
|
_threading.Thread(target=_auto_install, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_sshx() -> tuple[bool, bool]:
|
||||||
|
"""Ensure sshx is installed. Returns (success, needs_restart)."""
|
||||||
|
if shutil.which("sshx"):
|
||||||
|
return True, False
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
|
||||||
|
capture_output=True, timeout=60,
|
||||||
|
)
|
||||||
|
if shutil.which("sshx"):
|
||||||
|
return True, True # newly installed, needs restart
|
||||||
|
return False, False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("sshx install failed: %s", e)
|
logger.error("sshx install failed: %s", e)
|
||||||
return False
|
return False, False
|
||||||
|
|
||||||
|
|
||||||
def _trigger_dashboard_restart():
|
def _trigger_dashboard_restart():
|
||||||
|
|
@ -118,18 +148,23 @@ def _start_sshx_background():
|
||||||
|
|
||||||
|
|
||||||
@router.get("/start")
|
@router.get("/start")
|
||||||
async def start_sshx():
|
async def start_sshx(background_tasks: BackgroundTasks):
|
||||||
global _sshx_link, _sshx_pid
|
global _sshx_link, _sshx_pid
|
||||||
|
|
||||||
if _is_alive() and _sshx_link:
|
if _is_alive() and _sshx_link:
|
||||||
return {"status": "running", "link": _sshx_link}
|
return {"status": "running", "link": _sshx_link}
|
||||||
|
|
||||||
if not _ensure_sshx():
|
ok, needs_restart = _ensure_sshx()
|
||||||
|
if not ok:
|
||||||
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install sshx"})
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to install sshx"})
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
result = await loop.run_in_executor(None, _start_sshx_background)
|
result = await loop.run_in_executor(None, _start_sshx_background)
|
||||||
|
|
||||||
|
# Defer dashboard restart until AFTER the response is sent
|
||||||
|
if needs_restart:
|
||||||
|
background_tasks.add_task(_trigger_dashboard_restart)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
return {"status": "running", "link": _sshx_link}
|
return {"status": "running", "link": _sshx_link}
|
||||||
return JSONResponse(status_code=504, content={"status": "error", "message": "sshx did not return a link in time"})
|
return JSONResponse(status_code=504, content={"status": "error", "message": "sshx did not return a link in time"})
|
||||||
|
|
@ -137,9 +172,11 @@ async def start_sshx():
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def sshx_status():
|
async def sshx_status():
|
||||||
global _sshx_link
|
global _sshx_link, _sshx_installing
|
||||||
if _is_alive() and _sshx_link:
|
if _is_alive() and _sshx_link:
|
||||||
return {"status": "running", "link": _sshx_link}
|
return {"status": "running", "link": _sshx_link}
|
||||||
|
if _sshx_installing:
|
||||||
|
return {"status": "installing", "link": None}
|
||||||
return {"status": "stopped", "link": None}
|
return {"status": "stopped", "link": None}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue