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()
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):
"""Register the sshx_start tool."""
threading.Thread(target=_auto_install_sshx, daemon=True).start()
ctx.register_tool(
name="sshx_start",
toolset="sshx-link",

View File

@ -8,8 +8,16 @@
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) {
const res = await fetchJSON(path, { method: "POST" });
const res = await safeFetchJSON(path, { method: "POST" });
return res;
}
@ -22,7 +30,12 @@
setStatus("loading");
setError(null);
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) {
setLink(res.link);
setStatus("running");
@ -38,7 +51,7 @@
const stopSSHX = useCallback(async function () {
try {
await apiPost("/api/plugins/sshx-link/stop");
await safeFetchJSON("/api/plugins/sshx-link/stop", { method: "POST" });
setLink(null);
setStatus("idle");
} catch (err) {
@ -48,10 +61,12 @@
const checkStatus = useCallback(async function () {
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) {
setLink(res.link);
setStatus("running");
} else if (res.status === "installing") {
setStatus("installing");
}
} catch (_) {}
}, []);
@ -60,6 +75,28 @@
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) {
return React.createElement(Card, null,
React.createElement(CardHeader, null,

View File

@ -6,7 +6,7 @@ import shutil
import logging
import time
from typing import Optional
from fastapi import APIRouter
from fastapi import APIRouter, BackgroundTasks
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
@ -14,28 +14,58 @@ router = APIRouter()
_sshx_link = None
_sshx_pid = None
_sshx_installing = False
_sshx_log = "/tmp/sshx_link.log"
_sshx_pid_file = "/tmp/sshx_link.pid"
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
if already:
return True
def _auto_install():
global _sshx_installing
if shutil.which("sshx"):
logger.info("sshx already installed at %s", shutil.which("sshx"))
return
_sshx_installing = True
logger.info("Auto-installing sshx...")
try:
subprocess.run(
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
capture_output=True, timeout=60,
)
if shutil.which("sshx"):
logger.info("sshx installed successfully")
_trigger_dashboard_restart()
return True
return False
else:
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:
logger.error("sshx install failed: %s", e)
return False
return False, False
def _trigger_dashboard_restart():
@ -118,18 +148,23 @@ def _start_sshx_background():
@router.get("/start")
async def start_sshx():
async def start_sshx(background_tasks: BackgroundTasks):
global _sshx_link, _sshx_pid
if _is_alive() and _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"})
loop = asyncio.get_event_loop()
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:
return {"status": "running", "link": _sshx_link}
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")
async def sshx_status():
global _sshx_link
global _sshx_link, _sshx_installing
if _is_alive() and _sshx_link:
return {"status": "running", "link": _sshx_link}
if _sshx_installing:
return {"status": "installing", "link": None}
return {"status": "stopped", "link": None}