feat(rcon): discover commands and autocomplete server help
CI / validate (push) Successful in 8m20s
Release / release (push) Successful in 11m23s

This commit is contained in:
dmg
2026-09-14 15:52:56 -04:00
parent eb8ef18688
commit 999c27c7f9
7 changed files with 690 additions and 12 deletions
@@ -1,7 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// @vitest-environment jsdom
import { createElement } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RconConsole } from "@/components/rcon-console";
const actionState = vi.hoisted(() => ({
authorized: 0,
authFailure: false,
helpResponses: {} as Record<string, string>,
requestedBeforeSend: [] as boolean[],
selected: [] as unknown[],
transactionSelected: [] as unknown[],
updates: [] as Record<string, unknown>[],
@@ -17,6 +25,7 @@ const actionState = vi.hoisted(() => ({
vi.mock("@/lib/auth/require-admin", () => ({
requireAdminSession: async () => {
actionState.authorized += 1;
if (actionState.authFailure) throw new Error("REDIRECT:/admin/login");
return { email: "admin@example.test", name: "Admin" };
},
}));
@@ -73,8 +82,9 @@ vi.mock("@/lib/rcon-credentials", () => ({
vi.mock("@/lib/rcon-gateway", () => ({
executeRcon: async (connection: Record<string, unknown>, command: string) => {
actionState.requestedBeforeSend.push(actionState.audits.at(-1)?.type.endsWith(".requested") === true);
actionState.executions.push({ connection, command });
return actionState.gatewayResult;
return command in actionState.helpResponses ? { ok: true, response: actionState.helpResponses[command] } : actionState.gatewayResult;
},
testRconConnection: vi.fn(),
}));
@@ -115,8 +125,12 @@ const savedServer = {
};
describe("RCON server actions", () => {
afterEach(() => cleanup());
beforeEach(() => {
actionState.authorized = 0;
actionState.authFailure = false;
actionState.helpResponses = {};
actionState.requestedBeforeSend = [];
actionState.selected = [];
actionState.transactionSelected = [];
actionState.updates = [];
@@ -127,6 +141,55 @@ describe("RCON server actions", () => {
actionState.gatewayResult = { ok: true, response: "private response" };
});
it("routes UI discovery and usage through independently authorized, audit-before-send RCON actions", async () => {
actionState.selected = [savedServer];
actionState.helpResponses = {
help: "Help: Index (1/2)\n/leaf: Private protection description",
"help 2": "Help: Index (2/2)\n/tyrant: Tyrant features",
"help tyrant": "Usage: /tyrant <menu|status>",
};
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
expect(actionState.executions.map((entry) => entry.command)).toEqual(["help", "help 2", "help tyrant"]);
expect(actionState.authorized).toBe(3);
expect(actionState.requestedBeforeSend).toEqual([true, true, true]);
expect(actionState.audits).toHaveLength(6);
for (let index = 0; index < 6; index += 2) {
expect(actionState.audits[index]?.admin).toEqual({ email: "admin@example.test", name: "Admin" });
expect(actionState.audits[index]?.correlationId).toBe(actionState.audits[index + 1]?.correlationId);
}
const audit = JSON.stringify(actionState.audits);
expect(audit).not.toContain("Private protection description");
expect(audit).not.toContain("/tyrant <menu|status>");
expect(audit).not.toContain("decrypted-password");
});
it.each(["authorization", "enabled-connection", "audit"])("does not send discovery requests when %s fails", async (failure) => {
actionState.selected = failure === "enabled-connection" ? [] : [savedServer];
actionState.authFailure = failure === "authorization";
actionState.auditFailure = failure === "audit";
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(actionState.authorized).toBe(1);
expect(actionState.executions).toEqual([]);
expect(document.body.textContent).not.toContain("REDIRECT:");
});
it.each(["busy", "timeout", "unavailable"] as const)("preserves safe %s gateway outcomes for UI discovery", async (reason) => {
actionState.selected = [savedServer];
actionState.gatewayResult = { ok: false, reason };
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(actionState.executions).toHaveLength(1);
expect(actionState.audits[1]?.data).toEqual(expect.objectContaining({ success: false, reason }));
expect((screen.getByLabelText("Command") as HTMLInputElement).disabled).toBe(false);
});
it("independently authorizes every exported operation before accepting input", async () => {
await expect(createRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
await expect(updateRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
+186 -2
View File
@@ -1,8 +1,8 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const actionMocks = vi.hoisted(() => ({
execute: vi.fn(async (_previous: unknown, formData: FormData) => ({
@@ -41,6 +41,190 @@ const creative = {
};
describe("RconConsole", () => {
beforeEach(() => {
actionMocks.execute.mockClear();
actionMocks.execute.mockImplementation(async (_previous, formData) => {
const command = String(formData.get("command") ?? "");
const serverId = String(formData.get("serverId") ?? "");
const message = command === "help"
? "§eHelp: Index (1/2)\n§6Leaf: §fAll commands for Leaf\n§6/leaf: §fProtection"
: command === "help 2" ? "Help: Index (2/2)\n/tyrant: Tyrant features"
: command === "help tyrant" ? "Usage: /tyrant <menu|status|armor <helmet|boots>>"
: `Executed ${command}`;
return { status: "success", message, serverId };
});
});
it("refreshes server help and accepts suggestions without submitting or polluting recall", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
expect(screen.getByRole("option", { name: /tyrant Tyrant features/ })).toBeTruthy();
expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(false);
expect(input.value).toBe("tyrant ");
expect(screen.queryByRole("listbox")).toBeNull();
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2"]);
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("tyrant ");
expect(screen.getByLabelText("Terminal transcript").textContent).not.toContain("$ help");
});
it("fetches and caches usage on demand and offers contextual literal arguments", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
expect(screen.getByRole("option", { name: "menu From server help" })).toBeTruthy();
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(false);
expect(input.value).toBe("tyrant status ");
fireEvent.change(input, { target: { value: "tyrant armor " } });
fireEvent.click(screen.getByRole("option", { name: "helmet From server help" }));
expect(input.value).toBe("tyrant armor helmet ");
expect(document.activeElement).toBe(input);
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2", "help tyrant"]);
});
it("reports incomplete help safely and leaves unknown manual commands usable", async () => {
actionMocks.execute.mockRejectedValueOnce(new Error("private transport detail"));
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(document.body.textContent).not.toContain("private transport detail");
const input = screen.getByLabelText("Command") as HTMLInputElement;
expect(input.disabled).toBe(false);
fireEvent.change(input, { target: { value: "custom-plugin arbitrary value" } });
fireEvent.submit(input.form!);
await screen.findByText("Executed custom-plugin arbitrary value");
});
it("isolates caches by server, invalidates edited endpoints, and discards help on remount", async () => {
const storage = vi.spyOn(Storage.prototype, "setItem");
const view = render(<RconConsole servers={[server, creative]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
fireEvent.change(screen.getByLabelText("Server"), { target: { value: creative.id } });
let input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(screen.queryByLabelText("Command usage")).toBeNull();
fireEvent.change(screen.getByLabelText("Server"), { target: { value: server.id } });
input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
expect(screen.getByLabelText("Command usage")).toBeTruthy();
expect(actionMocks.execute).toHaveBeenCalledTimes(3);
view.rerender(<RconConsole servers={[{ ...server, host: "replacement.example.com" }, creative]} />);
expect(screen.queryByLabelText("Command usage")).toBeNull();
expect(screen.queryByRole("listbox")).toBeNull();
view.unmount();
render(<RconConsole servers={[server]} />);
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyr" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(storage).not.toHaveBeenCalled();
storage.mockRestore();
});
it("dismisses suggestions for history recall, restores drafts, and submits only intentionally", async () => {
render(<RconConsole servers={[server]} />);
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "list" } });
fireEvent.submit(input.form!);
await screen.findByText("Executed list");
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(input, { target: { value: "tyr" } });
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("tyr");
fireEvent.keyDown(input, { key: "Escape" });
expect(input.getAttribute("aria-expanded")).toBe("false");
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("list");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(input.value).toBe("tyr");
fireEvent.submit(input.form!);
await screen.findByText("Executed tyr");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["list", "help", "help 2", "tyr"]);
});
it("does not offer end-of-command replacement while editing in the middle", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
input.setSelectionRange(1, 1);
fireEvent.select(input);
expect(screen.queryByRole("listbox")).toBeNull();
expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(true);
expect(input.value).toBe("tyr");
});
it("stops pagination on server change and never publishes a late result to the new selection", async () => {
let resolve!: (value: { status: "success"; message: string; serverId: string }) => void;
actionMocks.execute.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
render(<RconConsole servers={[server, creative]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
fireEvent.change(screen.getByLabelText("Server"), { target: { value: creative.id } });
await act(async () => resolve({ status: "success", message: "Help: Index (1/9)\n/secretcmd: Private", serverId: server.id }));
expect(actionMocks.execute).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "secret" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(screen.getByText("Refresh commands to discover server help")).toBeTruthy();
});
it("lets manual execution interrupt discovery and waits for the in-flight help request", async () => {
let resolve!: (value: { status: "success"; message: string; serverId: string }) => void;
actionMocks.execute.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "list" } });
fireEvent.submit(input.form!);
expect(actionMocks.execute).toHaveBeenCalledTimes(1);
await act(async () => resolve({ status: "success", message: "Help: Index (1/9)\n/leaf: Protection", serverId: server.id }));
await screen.findByText("Executed list");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "list"]);
expect(input.disabled).toBe(false);
});
it("caches safe usage failure without retries until refresh and keeps manual entry available", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
actionMocks.execute.mockRejectedValueOnce(new Error("private usage detail"));
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
await screen.findByText("Usage unavailable. Refresh commands to retry.");
fireEvent.change(input, { target: { value: "tyrant armor " } });
expect(actionMocks.execute).toHaveBeenCalledTimes(3);
expect(document.body.textContent).not.toContain("private usage detail");
expect(input.disabled).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("/tyrant <menu|status|armor <helmet|boots>>");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2", "help tyrant", "help", "help 2", "help tyrant"]);
});
it("renders server help as inert text and never refreshes missing or disabled connections", async () => {
const view = render(<RconConsole servers={[]} />);
expect((screen.getByRole("button", { name: "Refresh commands" }) as HTMLButtonElement).disabled).toBe(true);
view.rerender(<RconConsole servers={[{ ...server, enabled: false }]} />);
expect((screen.getByRole("button", { name: "Refresh commands" }) as HTMLButtonElement).disabled).toBe(true);
expect(actionMocks.execute).not.toHaveBeenCalled();
view.rerender(<RconConsole servers={[server]} />);
actionMocks.execute.mockResolvedValueOnce({ status: "success", message: 'Help: Index (1/1)\n/leaf: <img src=x onerror="alert(1)">', serverId: server.id });
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("1 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "lea" } });
expect(screen.getByText('<img src=x onerror="alert(1)">')).toBeTruthy();
expect(document.querySelector("img")).toBeNull();
});
it("renders one wide terminal workspace with connection controls and modal forms", () => {
const markup = renderToStaticMarkup(<RconConsole servers={[server]} />);
expect(markup).toContain('aria-label="RCON terminal"');
+80 -7
View File
@@ -1,19 +1,18 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
createRconServer,
deleteRconServer,
executeRconCommand,
setRconServerEnabled,
testSavedRconServer,
type RconCommandState,
updateRconServer,
} from "@/app/admin/(console)/rcon/actions";
import { AdminModalForm } from "@/components/admin-modal-form";
import { rconSuggestions, type RconSuggestion } from "@/lib/rcon-help";
import { useRconSession } from "./use-rcon-session";
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
const MAX_COMMAND_HISTORY = 50;
const MAX_TRANSCRIPT_EXCHANGES = 50;
@@ -46,8 +45,9 @@ export function RconConsole({
servers: RconServerOption[];
}) {
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
const [state, action, pending] = useActionState(executeRconCommand, initialState);
const [command, setCommand] = useState("");
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
const [activeSuggestion, setActiveSuggestion] = useState(0);
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [transcript, setTranscript] = useState<TranscriptExchange[]>([]);
@@ -57,11 +57,27 @@ export function RconConsole({
const pendingExchangeIdRef = useRef<number | null>(null);
const transcriptRef = useRef<HTMLDivElement>(null);
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
const help = useRconSession(selected, command);
const { state, action, pending } = help;
const suggestions = selected?.enabled && !pending && suggestionsOpen
? rconSuggestions(command, help.catalog?.commands ?? [], help.usage?.text) : [];
const suggestionIndex = Math.min(activeSuggestion, Math.max(0, suggestions.length - 1));
function acceptSuggestion(suggestion: RconSuggestion) {
setCommand(suggestion.value);
setSuggestionsOpen(false);
setHistoryIndex(null);
inputRef.current?.focus();
}
useEffect(() => {
inputRef.current?.focus();
}, [selectedId]);
useEffect(() => {
if (suggestions.length) document.getElementById(`rcon-suggestion-${suggestionIndex}`)?.scrollIntoView?.({ block: "nearest" });
}, [suggestionIndex, suggestions.length]);
useEffect(() => {
if (!pending && state.status !== "idle") inputRef.current?.focus();
}, [pending, state.status]);
@@ -101,6 +117,8 @@ export function RconConsole({
}
function rememberSubmittedCommand() {
help.stopHelp();
setSuggestionsOpen(false);
const submitted = command.trim();
if (!submitted || !selected) return;
const exchangeId = ++nextExchangeIdRef.current;
@@ -133,7 +151,11 @@ export function RconConsole({
<select
className="max-w-full border border-line bg-panel px-3 py-2 font-mono text-xs font-bold normal-case outline-none focus:border-accent"
id="rcon-console-server"
onChange={(event) => setSelectedId(event.target.value)}
onChange={(event) => {
help.stopHelp();
setSuggestionsOpen(false);
setSelectedId(event.target.value);
}}
value={selected?.id}
>
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}{server.enabled ? "" : " — disabled"}</option>)}
@@ -200,11 +222,39 @@ export function RconConsole({
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-line bg-canvas px-4 py-2 font-mono text-[10px] text-muted">
<p aria-live="polite" role="status">{help.loading ? "Reading server help…" : help.message}</p>
<button className="border border-line px-3 py-2 font-bold uppercase tracking-wider hover:border-ink disabled:opacity-50" disabled={!selected?.enabled || pending || help.loading} onClick={() => void help.refresh()} type="button">Refresh commands</button>
</div>
{help.usage && (
<div aria-label="Command usage" className="border-t border-line bg-panel px-4 py-3 font-mono text-xs" id="rcon-usage">
<p className="mb-1 text-[9px] uppercase tracking-wider text-muted">Server usage hint</p>
<pre className="whitespace-pre-wrap break-words font-mono text-xs">{help.usage.text ?? (help.usage.failed ? "Usage unavailable. Refresh commands to retry." : "No usage supplied by server help.")}</pre>
</div>
)}
{suggestions.length > 0 && (
<div className="border-t border-line bg-panel px-4 py-3 font-mono text-xs">
<p className="mb-2 text-[9px] uppercase tracking-wider text-muted" id="rcon-suggestion-hint"> Select · Tab / Enter Insert · Esc Dismiss</p>
<ul aria-label="Command suggestions" className="max-h-48 overflow-y-auto" id="rcon-suggestions" role="listbox">
{suggestions.map((suggestion, index) => (
<li aria-selected={index === suggestionIndex} className={`flex cursor-pointer flex-wrap gap-x-4 gap-y-1 border-l-2 px-3 py-2 ${index === suggestionIndex ? "border-accent bg-canvas text-ink" : "border-transparent text-muted"}`} id={`rcon-suggestion-${index}`} key={suggestion.value} onClick={() => acceptSuggestion(suggestion)} onMouseDown={(event) => event.preventDefault()} role="option">
<span className="font-bold">{suggestion.label}</span>{" "}<span className="break-words">{suggestion.description}</span>
</li>
))}
</ul>
</div>
)}
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3" onSubmit={rememberSubmittedCommand}>
<input name="serverId" type="hidden" value={selected?.id ?? ""} />
<span aria-hidden="true" className="font-mono text-lg font-black text-accent">$</span>
<label className="sr-only" htmlFor="rcon-command">Command</label>
<input
aria-activedescendant={suggestions.length ? `rcon-suggestion-${suggestionIndex}` : undefined}
aria-autocomplete="list"
aria-controls={suggestions.length ? "rcon-suggestions" : undefined}
aria-describedby={[suggestions.length ? "rcon-suggestion-hint" : "", help.usage ? "rcon-usage" : ""].filter(Boolean).join(" ") || undefined}
aria-expanded={suggestions.length > 0}
role="combobox"
autoComplete="off"
autoFocus
className="min-w-0 flex-1 bg-transparent px-1 py-2 font-mono text-sm outline-none placeholder:text-muted focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
@@ -213,8 +263,31 @@ export function RconConsole({
key={selected?.id ?? "no-server"}
maxLength={1024}
name="command"
onChange={(event) => setCommand(event.target.value)}
onSelect={(event) => {
const input = event.currentTarget;
if (input.selectionStart !== input.value.length || input.selectionEnd !== input.value.length) setSuggestionsOpen(false);
}}
onBlur={() => setSuggestionsOpen(false)}
onChange={(event) => {
setCommand(event.target.value);
setSuggestionsOpen(true);
setActiveSuggestion(0);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Escape") { setSuggestionsOpen(false); return; }
if (suggestions.length) {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
setActiveSuggestion((suggestionIndex + (event.key === "ArrowUp" ? -1 : 1) + suggestions.length) % suggestions.length);
return;
}
if ((event.key === "Tab" && !event.shiftKey) || event.key === "Enter") {
event.preventDefault();
acceptSuggestion(suggestions[suggestionIndex]!);
return;
}
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
navigateHistory(event.key === "ArrowUp" ? "older" : "newer");
@@ -0,0 +1,97 @@
"use client";
import { useActionState, useCallback, useEffect, useRef, useState } from "react";
import { executeRconCommand, type RconCommandState } from "@/app/admin/(console)/rcon/actions";
import { discoverRconCommands, parseRconHelp, type RconHelpCatalog } from "@/lib/rcon-help";
import type { RconServerOption } from "./rcon-console";
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
type UsageResult = { text: string | null; failed: boolean };
type HelpCache = RconHelpCatalog & { usage: Map<string, UsageResult> };
export function useRconSession(server: RconServerOption | undefined, command: string) {
// Endpoint edits/enable changes invalidate the old cache, not just selection changes.
const key = server ? JSON.stringify([server.id, server.host, server.port, server.enabled]) : "";
const [cache, setCache] = useState(new Map<string, HelpCache>());
const [loading, setLoading] = useState(false);
const operationRef = useRef<AbortController | null>(null);
const flightRef = useRef<Promise<string> | null>(null);
function stopHelp() { operationRef.current?.abort(); }
useEffect(() => () => { operationRef.current?.abort(); }, [key]);
const [state, action, pending] = useActionState(async (previous: RconCommandState, formData: FormData) => {
// Do not race the page's help lookup against a manual command for the gateway lock.
stopHelp();
await flightRef.current?.catch(() => undefined);
return executeRconCommand(previous, formData);
}, initialState);
const serverId = server?.id ?? "";
const requestHelp = useCallback((helpCommand: string): Promise<string> => {
const data = new FormData();
data.set("serverId", serverId);
data.set("command", helpCommand);
const flight = executeRconCommand(initialState, data).then((result) => {
if (result.status !== "success") throw new Error("Help request unavailable");
return result.message;
});
flightRef.current = flight;
return flight;
}, [serverId]);
async function refresh() {
if (!server?.enabled || pending || operationRef.current) return;
const controller = new AbortController();
operationRef.current = controller;
setLoading(true);
try {
const result = await discoverRconCommands(requestHelp, controller.signal);
if (!controller.signal.aborted) {
setCache((current) => new Map(current).set(key, { ...result, usage: new Map() }));
}
} finally {
operationRef.current = null;
setLoading(false);
}
}
const catalog = cache.get(key);
const typedVerb = command.match(/^\/?([a-z0-9_.:-]+)\s/i)?.[1];
const verb = catalog?.commands.find((entry) => entry.name.toLowerCase() === typedVerb?.toLowerCase())?.name;
const usage = verb ? catalog?.usage.get(verb) : undefined;
const enabled = server?.enabled ?? false;
useEffect(() => {
if (!enabled || !verb || usage || pending || loading) return;
const timer = setTimeout(async () => {
if (operationRef.current) return;
const controller = new AbortController();
operationRef.current = controller;
setLoading(true);
let result: UsageResult;
try {
result = { text: parseRconHelp(await requestHelp(`help ${verb}`)).usage, failed: false };
} catch {
result = { text: null, failed: true };
}
if (!controller.signal.aborted) {
setCache((current) => {
const entry = current.get(key);
if (!entry) return current;
return new Map(current).set(key, { ...entry, usage: new Map(entry.usage).set(verb, result) });
});
}
operationRef.current = null;
setLoading(false);
}, 350);
return () => clearTimeout(timer);
}, [enabled, key, verb, usage, pending, loading, requestHelp]);
const message = catalog
? catalog.incomplete
? `${catalog.commands.length} commands discovered · incomplete; refresh to retry`
: `${catalog.commands.length} commands discovered · best-effort server help`
: "Refresh commands to discover server help";
return { state, action, pending, catalog, usage, loading, refresh, stopHelp, message };
}
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { discoverRconCommands, MAX_HELP_COMMANDS, MAX_HELP_PAGES, parseRconHelp, rconSuggestions } from "./rcon-help";
describe("RCON help discovery", () => {
it("walks help pages sequentially and deduplicates commands", async () => {
const requests: string[] = [];
const result = await discoverRconCommands(async (command) => {
requests.push(command);
return command === "help"
? "Help: Index (1/2)\nLeaf: All commands for Leaf\n/leaf: Protection"
: "Help: Index (2/2)\n/leaf: Protection\n/tyrant: Tyrant features";
});
expect(requests).toEqual(["help", "help 2"]);
expect(result).toEqual({ commands: [
{ name: "leaf", description: "Protection" },
{ name: "tyrant", description: "Tyrant features" },
], incomplete: false });
});
it.each([
"Unrecognized help format",
"Help: Index (0/2)",
"Help: Index (1/0)",
"Help: Index (1/9007199254740992)",
])("stops on malformed pagination: %s", async (output) => {
let calls = 0;
const result = await discoverRconCommands(async () => { calls++; return output; });
expect(calls).toBe(1);
expect(result.incomplete).toBe(true);
});
it("stops on repeated pages, transport failure, or cancellation without losing earlier commands", async () => {
const first = "Help: Index (1/3)\n/leaf: Protection";
let calls = 0;
const repeated = await discoverRconCommands(async () => { calls++; return first; });
expect(calls).toBe(2);
expect(repeated).toEqual({ commands: [{ name: "leaf", description: "Protection" }], incomplete: true });
calls = 0;
const failed = await discoverRconCommands(async () => {
if (calls++) throw new Error("private transport detail");
return first;
});
expect(failed).toEqual(repeated);
const controller = new AbortController();
const cancelled = await discoverRconCommands(async () => { controller.abort(); return first; }, controller.signal);
expect(cancelled).toEqual({ commands: [], incomplete: true });
});
it("bounds cached command count independently of pagination", async () => {
let calls = 0;
const result = await discoverRconCommands(async () => {
calls++;
return `Help: Index (${calls}/64)\n` + Array.from({ length: 100 }, (_, index) => `/c${calls}_${index}: A command`).join("\n");
});
expect(result.commands).toHaveLength(MAX_HELP_COMMANDS);
expect(result.incomplete).toBe(true);
expect(calls).toBeLessThan(MAX_HELP_PAGES);
});
it("caps excessive pagination and reports partial results", async () => {
let calls = 0;
const result = await discoverRconCommands(async () => `Help: Index (${++calls}/99999)\n/c${calls}: A command`);
expect(calls).toBe(MAX_HELP_PAGES);
expect(result.commands).toHaveLength(MAX_HELP_PAGES);
expect(result.incomplete).toBe(true);
});
});
describe("RCON suggestions and usage", () => {
it("filters discovered names without requiring a slash and preserves a typed slash", () => {
const commands = [{ name: "tyrant", description: "Tyrant features" }, { name: "leaf", description: "Protection" }];
expect(rconSuggestions("tyr", commands)).toEqual([{ value: "tyrant ", label: "tyrant", description: "Tyrant features" }]);
expect(rconSuggestions("/tyr", commands)[0]?.value).toBe("/tyrant ");
expect(rconSuggestions("unknown", commands)).toEqual([]);
expect(rconSuggestions("", commands)).toEqual([]);
});
it("reads wrapped Tyrant usage and offers only context-correct literal branches", () => {
const { usage } = parseRconHelp(`§e--------- §fHelp: §r/tyrant §e-------------------------------
§6Description: §fView and use Spigot Tyrant game features.
§f§6Usage: §f/tyrant <menu|status|choices|buy|armor
§f<helmet|chestplate|leggings|boots>|gear
§f<axe|pickaxe|sword|hoe|shovel>|assign|item|intelligence
§f|optout|optin|relinquish confirm>`);
expect(usage).toContain("/tyrant <menu|status");
expect(usage).not.toContain("§");
const commands = [{ name: "tyrant", description: "Tyrant features" }];
expect(rconSuggestions("tyrant ", commands, usage).map((item) => item.label)).toEqual([
"menu", "status", "choices", "buy", "armor", "gear", "assign", "item", "intelligence", "optout", "optin", "relinquish",
]);
expect(rconSuggestions("tyrant armor he", commands, usage).map((item) => item.value)).toEqual(["tyrant armor helmet "]);
expect(rconSuggestions("tyrant relinquish ", commands, usage).map((item) => item.label)).toEqual(["confirm"]);
});
it.each([
"/leaf <player>", "/leaf [player]", "/leaf <a|b", "/other <a|b>",
"/leaf <a||b>", "/leaf <a|b>>", "/leaf <a|b> ".repeat(300),
"/leaf " + "<a|b> ".repeat(10),
])("does not invent literals for opaque or malformed syntax: %s", (usage) => {
expect(rconSuggestions("leaf ", [{ name: "leaf", description: "Protection" }], usage)).toEqual([]);
});
});
describe("RCON help parsing", () => {
it("discovers slash commands, not help categories, from formatted paginated output", () => {
expect(parseRconHelp(`§e--------- §fHelp: §rIndex (1/31) §e--------------------------
§7Use /help [n] to get page n of help.
§7§6Aliases: §fLists command aliases
§f§6Leaf: §fAll commands for Leaf
§f§6/leaf: §fControl or administer Leaf protection.
§f§6/minecraft:help: §fProvides help
§f§6/not a command: §fIgnore this
`)).toEqual({
commands: [
{ name: "leaf", description: "Control or administer Leaf protection." },
{ name: "minecraft:help", description: "Provides help" },
],
page: 1, pages: 31, usage: null,
});
});
});
+127
View File
@@ -0,0 +1,127 @@
export type RconHelpCommand = { name: string; description: string };
export type RconHelpPage = {
commands: RconHelpCommand[];
page: number | null;
pages: number | null;
usage: string | null;
};
export type RconHelpCatalog = { commands: RconHelpCommand[]; incomplete: boolean };
export const MAX_HELP_PAGES = 64;
export const MAX_HELP_COMMANDS = 2_048;
export async function discoverRconCommands(
request: (command: string) => Promise<string>,
signal?: AbortSignal,
): Promise<RconHelpCatalog> {
const commands = new Map<string, RconHelpCommand>();
let total: number | null = null;
for (let page = 1; page <= MAX_HELP_PAGES; page += 1) {
if (signal?.aborted) break;
let parsed: RconHelpPage;
try {
parsed = parseRconHelp(await request(page === 1 ? "help" : `help ${page}`));
} catch {
break;
}
if (signal?.aborted) break;
for (const command of parsed.commands) {
if (commands.size >= MAX_HELP_COMMANDS) return { commands: [...commands.values()], incomplete: true };
commands.set(command.name.toLowerCase(), command);
}
if (total === null) total = parsed.pages;
if (parsed.page !== page || parsed.pages !== total || !Number.isSafeInteger(total) || total! < page) break;
if (page === total) return { commands: [...commands.values()], incomplete: false };
}
return { commands: [...commands.values()], incomplete: true };
}
export type RconSuggestion = { value: string; label: string; description: string };
// Only expand a small, balanced literal/alternative grammar. Single angle-bracket
// values are placeholders, not literals; unsupported syntax remains a usage hint.
function usagePaths(pattern: string): Array<Array<string | null>> {
if (pattern.length > 4_096 || /[^a-z0-9_.:<>|\s-]/i.test(pattern)) return [];
const tokens = pattern.match(/[a-z0-9_.:-]+|[<>|]/gi) ?? [];
if (tokens.length > 256) return [];
let cursor = 0;
function expression(depth: number): Array<Array<string | null>> {
if (depth > 8) throw new Error("Complex usage");
const alternatives: Array<Array<string | null>> = [];
let sequence: Array<Array<string | null>> = [[]];
let hasAlternatives = false;
while (cursor < tokens.length && tokens[cursor] !== ">") {
const token = tokens[cursor++]!;
if (token === "|") {
if (sequence.some((path) => !path.length)) throw new Error("Empty alternative");
alternatives.push(...sequence);
sequence = [[]];
hasAlternatives = true;
continue;
}
const values = token === "<" ? expression(depth + 1) : [[token]];
if (token === "<" && tokens[cursor++] !== ">") throw new Error("Unbalanced usage");
if (sequence.length * values.length + alternatives.length > 256) throw new Error("Complex usage");
sequence = sequence.flatMap((prefix) => values.map((suffix) => [...prefix, ...suffix]));
}
if (sequence.some((path) => !path.length)) throw new Error("Empty alternative");
alternatives.push(...sequence);
return depth > 0 && !hasAlternatives ? [[null]] : alternatives;
}
try {
const paths = expression(0);
return cursor === tokens.length ? paths : [];
} catch {
return [];
}
}
export function rconSuggestions(input: string, commands: RconHelpCommand[], usage?: string | null): RconSuggestion[] {
if (!input || input.length > 1_024) return [];
const slash = input.startsWith("/") ? "/" : "";
const parts = input.replace(/^\//, "").split(/\s+/);
const verb = parts[0]!;
if (parts.length === 1) {
return commands.filter((command) => command.name.toLowerCase().startsWith(verb.toLowerCase()))
.slice(0, 20).map((command) => ({ value: `${slash}${command.name} `, label: command.name, description: command.description }));
}
if (!commands.some((command) => command.name.toLowerCase() === verb.toLowerCase()) || !usage) return [];
const match = usage.match(/^\/?([a-z0-9_.:-]+)\s+([\s\S]+)$/i);
if (!match || match[1]!.toLowerCase() !== verb.toLowerCase()) return [];
const entered = parts.slice(1, -1);
const prefix = parts.at(-1)!;
const literals = new Set<string>();
for (const path of usagePaths(match[2]!)) {
if (!entered.every((value, index) => path[index] === value)) continue;
const next = path[entered.length];
if (next && next.startsWith(prefix)) literals.add(next);
}
const base = input.slice(0, input.length - prefix.length);
return [...literals].slice(0, 20).map((literal) => ({ value: `${base}${literal} `, label: literal, description: "From server help" }));
}
export function parseRconHelp(output: string): RconHelpPage {
const text = output.slice(0, 65_536).replace(/§[0-9a-fk-orx]/gi, "");
const pagination = text.match(/^.*Help:.*\((\d+)\/(\d+)\).*$/m);
const commands: RconHelpCommand[] = [];
for (const line of text.split(/\r?\n/)) {
const entry = line.trim().match(/^\/([a-z0-9_.:-]{1,128}):\s+(.+)$/i);
if (entry) commands.push({ name: entry[1]!, description: entry[2]!.slice(0, 512) });
}
const lines = text.split(/\r?\n/).map((line) => line.trim());
const usageStart = lines.findIndex((line) => /^Usage:\s*/i.test(line));
const usageLines: string[] = [];
if (usageStart !== -1) {
usageLines.push(lines[usageStart]!.replace(/^Usage:\s*/i, ""));
for (const line of lines.slice(usageStart + 1)) {
if (!line || /^[a-z][a-z ]+:\s|^-{3}/i.test(line)) break;
usageLines.push(line);
}
}
return {
commands,
page: pagination ? Number(pagination[1]) : null,
pages: pagination ? Number(pagination[2]) : null,
usage: usageLines.length ? usageLines.join("\n").slice(0, 4_096) : null,
};
}