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:
OpenCode Agent 2026-08-19 00:00:34 +00:00
parent d25b984eac
commit ffece76498
3 changed files with 108 additions and 16 deletions

View File

@ -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",

View File

@ -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,

View File

@ -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}