feat: hermes sshx-link plugin - shared terminal from dashboard
This commit is contained in:
commit
6aaf913366
|
|
@ -0,0 +1,106 @@
|
||||||
|
"""
|
||||||
|
sshx-link plugin
|
||||||
|
================
|
||||||
|
Downloads sshx, starts a shared terminal session,
|
||||||
|
and returns the shareable link.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_sshx_process = None
|
||||||
|
_sshx_link = None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_link_from_output(line: str) -> str | None:
|
||||||
|
"""Extract sshx link from process output."""
|
||||||
|
# sshx outputs links like: https://sshx.io/s/xxxxx
|
||||||
|
match = re.search(r"https://sshx\.io/s/[A-Za-z0-9_-]+", line)
|
||||||
|
return match.group(0) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_sshx_installed() -> bool:
|
||||||
|
"""Download and install sshx binary if not present."""
|
||||||
|
if shutil.which("sshx"):
|
||||||
|
logger.info("sshx already installed at %s", shutil.which("sshx"))
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info("Downloading 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:
|
||||||
|
logger.error("sshx install failed: %s", result.stderr)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("sshx install timed out")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _run_sshx(tool_ctx):
|
||||||
|
"""Run sshx serve in background, capture the link."""
|
||||||
|
global _sshx_process, _sshx_link
|
||||||
|
|
||||||
|
if _sshx_process and _sshx_process.poll() is None:
|
||||||
|
# Already running
|
||||||
|
tool_ctx.yield_result({"status": "already_running", "link": _sshx_link})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not _ensure_sshx_installed():
|
||||||
|
tool_ctx.yield_result({"status": "error", "message": "Failed to install sshx"})
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Starting sshx serve...")
|
||||||
|
_sshx_process = subprocess.Popen(
|
||||||
|
["sshx", "serve"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
_sshx_link = None
|
||||||
|
|
||||||
|
def _reader():
|
||||||
|
global _sshx_link
|
||||||
|
for line in _sshx_process.stdout:
|
||||||
|
line = line.strip()
|
||||||
|
logger.debug("sshx: %s", line)
|
||||||
|
link = _find_link_from_output(line)
|
||||||
|
if link:
|
||||||
|
_sshx_link = link
|
||||||
|
tool_ctx.yield_result({"status": "running", "link": link})
|
||||||
|
return
|
||||||
|
# Process ended without outputting a link
|
||||||
|
tool_ctx.yield_result({"status": "error", "message": "sshx exited without producing a link"})
|
||||||
|
|
||||||
|
threading.Thread(target=_reader, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def register(ctx):
|
||||||
|
"""Register the sshx_start tool."""
|
||||||
|
ctx.register_tool(
|
||||||
|
name="sshx_start",
|
||||||
|
toolset="sshx-link",
|
||||||
|
schema={
|
||||||
|
"description": "Start a sshx shared terminal session and return the shareable link. The link lets others join your terminal via browser.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler=_run_sshx,
|
||||||
|
)
|
||||||
|
logger.info("sshx-link plugin registered")
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
(function() {
|
||||||
|
const SDK = window.__HERMES_PLUGIN_SDK__;
|
||||||
|
const { useState, useEffect, useCallback } = React;
|
||||||
|
|
||||||
|
function SSHXPage() {
|
||||||
|
const [link, setLink] = useState(null);
|
||||||
|
const [status, setStatus] = useState("idle"); // idle | loading | running | error
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const startSSHX = useCallback(async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await SDK.fetchJSON("/api/plugins/sshx-link/start");
|
||||||
|
if (res.link) {
|
||||||
|
setLink(res.link);
|
||||||
|
setStatus("running");
|
||||||
|
} else {
|
||||||
|
setError(res.message || "No link returned");
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || "Failed to start sshx");
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopSSHX = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/plugins/sshx-link/stop", { method: "POST" });
|
||||||
|
setLink(null);
|
||||||
|
setStatus("idle");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await SDK.fetchJSON("/api/plugins/sshx-link/status");
|
||||||
|
if (res.status === "running" && res.link) {
|
||||||
|
setLink(res.link);
|
||||||
|
setStatus("running");
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { checkStatus(); }, [checkStatus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "2rem", maxWidth: 600, margin: "0 auto" }}>
|
||||||
|
<h1 style={{ fontSize: "1.5rem", fontWeight: 600, marginBottom: "1rem" }}>
|
||||||
|
SSHX Shared Terminal
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: "#888", marginBottom: "1.5rem" }}>
|
||||||
|
Start a shared terminal session. Others can join via the link.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{status === "running" && link ? (
|
||||||
|
<div style={{
|
||||||
|
background: "#1a1a2e",
|
||||||
|
border: "1px solid #0f3460",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "1.5rem",
|
||||||
|
marginBottom: "1rem"
|
||||||
|
}}>
|
||||||
|
<p style={{ color: "#4ade80", fontWeight: 600, marginBottom: "0.5rem" }}>
|
||||||
|
Running
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
color: "#60a5fa",
|
||||||
|
fontSize: "1.1rem",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
textDecoration: "underline"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link}
|
||||||
|
</a>
|
||||||
|
<div style={{ marginTop: "1rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={stopSSHX}
|
||||||
|
style={{
|
||||||
|
background: "#dc2626",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 500
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={startSSHX}
|
||||||
|
disabled={status === "loading"}
|
||||||
|
style={{
|
||||||
|
background: status === "loading" ? "#555" : "#2563eb",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "0.7rem 1.5rem",
|
||||||
|
cursor: status === "loading" ? "not-allowed" : "pointer",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "1rem"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === "loading" ? "Starting..." : "Start SSHX"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: "#f87171", marginTop: "1rem" }}>
|
||||||
|
Error: {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the tab component
|
||||||
|
if (window.__HERMES_PLUGINS__) {
|
||||||
|
window.__HERMES_PLUGINS__.register("sshx-link", SSHXPage);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "sshx-link",
|
||||||
|
"label": "SSHX",
|
||||||
|
"description": "Start a shared terminal session and get the link",
|
||||||
|
"icon": "Terminal",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"tab": {
|
||||||
|
"path": "/sshx",
|
||||||
|
"position": "end"
|
||||||
|
},
|
||||||
|
"entry": "dist/index.js",
|
||||||
|
"api": "plugin_api.py"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""
|
||||||
|
sshx-link dashboard backend
|
||||||
|
===========================
|
||||||
|
FastAPI routes for starting sshx and returning the share link.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Keep track of running sshx process
|
||||||
|
_sshx_process = None
|
||||||
|
_sshx_link = None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_link(text: str) -> str | None:
|
||||||
|
"""Extract sshx link from output."""
|
||||||
|
match = re.search(r"https://sshx\.io/s/[A-Za-z0-9_-]+", text)
|
||||||
|
return match.group(0) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_sshx() -> bool:
|
||||||
|
"""Install sshx if not present."""
|
||||||
|
if shutil.which("sshx"):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["sh", "-c", "curl -sSf https://sshx.io/get | sh"],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
return shutil.which("sshx") is not None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("sshx install failed: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/start")
|
||||||
|
async def start_sshx():
|
||||||
|
"""Start sshx serve and return the share link."""
|
||||||
|
global _sshx_process, _sshx_link
|
||||||
|
|
||||||
|
# Already running
|
||||||
|
if _sshx_process and _sshx_process.poll() is None and _sshx_link:
|
||||||
|
return {"status": "running", "link": _sshx_link}
|
||||||
|
|
||||||
|
# Install if needed
|
||||||
|
if not _ensure_sshx():
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"status": "error", "message": "Failed to install sshx"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kill old process if any
|
||||||
|
if _sshx_process and _sshx_process.poll() is None:
|
||||||
|
_sshx_process.terminate()
|
||||||
|
_sshx_process.wait(timeout=5)
|
||||||
|
|
||||||
|
# Start sshx serve
|
||||||
|
_sshx_process = subprocess.Popen(
|
||||||
|
["sshx", "serve"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
_sshx_link = None
|
||||||
|
|
||||||
|
# Read output in background thread, await link
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
link_future = loop.create_future()
|
||||||
|
|
||||||
|
def _reader():
|
||||||
|
global _sshx_link
|
||||||
|
for line in _sshx_process.stdout:
|
||||||
|
line = line.strip()
|
||||||
|
link = _find_link(line)
|
||||||
|
if link:
|
||||||
|
_sshx_link = link
|
||||||
|
if not link_future.done():
|
||||||
|
link_future.set_result(link)
|
||||||
|
return
|
||||||
|
if not link_future.done():
|
||||||
|
link_future.set_exception RuntimeError("sshx exited without link")
|
||||||
|
|
||||||
|
import threading
|
||||||
|
threading.Thread(target=_reader, daemon=True).start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
link = await asyncio.wait_for(link_future, timeout=30)
|
||||||
|
return {"status": "running", "link": link}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=504,
|
||||||
|
content={"status": "error", "message": "sshx did not return a link in time"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"status": "error", "message": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def sshx_status():
|
||||||
|
"""Check if sshx is running and return the link."""
|
||||||
|
global _sshx_process, _sshx_link
|
||||||
|
|
||||||
|
if _sshx_process and _sshx_process.poll() is None and _sshx_link:
|
||||||
|
return {"status": "running", "link": _sshx_link}
|
||||||
|
return {"status": "stopped", "link": None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stop")
|
||||||
|
async def stop_sshx():
|
||||||
|
"""Stop the running sshx process."""
|
||||||
|
global _sshx_process, _sshx_link
|
||||||
|
|
||||||
|
if _sshx_process and _sshx_process.poll() is None:
|
||||||
|
_sshx_process.terminate()
|
||||||
|
try:
|
||||||
|
_sshx_process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
_sshx_process.kill()
|
||||||
|
_sshx_process = None
|
||||||
|
_sshx_link = None
|
||||||
|
return {"status": "stopped"}
|
||||||
|
return {"status": "already_stopped"}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
name: sshx-link
|
||||||
|
version: 1.0.0
|
||||||
|
description: Start sshx shared terminal and return the share link
|
||||||
|
author: sisyphus
|
||||||
|
provides_tools:
|
||||||
|
- sshx_start
|
||||||
|
provides_hooks: []
|
||||||
|
requires_env: []
|
||||||
Loading…
Reference in New Issue