import { bindSshAuthentication, clearFieldError, createCredentialSshGuard, markRejectedField } from "./ssh-form.js"; import { flashCopied, readWizardSession, whenVisible, wizardApiError } from "./session.js"; import { initWizardTopbar } from "./topbar.js"; const token = readWizardSession(); const element = (id) => document.getElementById(id); const state = { busy: false, fingerprint: null, plan: null, requestGeneration: 0, loginGeneration: 0, pollTimer: null, progressTimer: null, progressStartedAt: 0, status: null, }; let sshIdentityDecision = 0; const hostReport = () => ({ flow: "supergrok", container: element("container").value, network: element("network").value }); const sshSession = createCredentialSshGuard({ token, hostReport, onIdentityDecision() { sshIdentityDecision++; }, onMismatch() { invalidateBoundaryState(); element("container").replaceChildren(); element("network").replaceChildren(); element("ssh-panel").hidden = false; element("selection-panel").hidden = true; resetHost(); setStage(1); } }); const actionLabels = { install: "Install a fresh private SuperGrok companion", "sign-in": "Start a fresh official Grok device sign-in", "sign-out": "Sign out only this companion's Grok session", remove: "Remove this companion and its Grok session volume", "cancel-sign-in": "Cancel this companion's pending device sign-in", }; function setMessage(text) { element("status-message").textContent = text; } function showError(error) { const text = markRejectedField(error?.message ?? "The operation could not be completed.", element, "error-message"); element("error-message").textContent = text; element("error-message").hidden = false; element("error-message").focus(); globalThis.relmioGuide?.error?.(error); } function clearError() { element("error-message").hidden = true; element("error-message").textContent = ""; globalThis.relmioGuide?.clearError?.(); } function setStage(stage) { document.body.dataset.stage = String(stage); for (const marker of document.querySelectorAll("[data-stage-marker]")) { const markerStage = Number(marker.dataset.stageMarker); if (markerStage < stage) marker.dataset.state = "done"; else delete marker.dataset.state; if (markerStage === stage) marker.setAttribute("aria-current", "step"); else marker.removeAttribute("aria-current"); } syncView(); } // One step panel is visible at a time: the panel for the current stage, or the // nearest available panel while a reviewed action is being applied. const STAGE_VIEWS = { 1: ["ssh-panel", "selection-panel"], 2: ["selection-panel", "ssh-panel"], 3: ["review-panel", "settings-panel", "selection-panel", "ssh-panel"], 4: ["settings-panel", "selection-panel", "ssh-panel"], }; let currentView = "ssh-panel"; function focusVisible(target) { target.focus({ preventScroll: true }); const rect = target.getBoundingClientRect(); const footer = target.closest(".rm-panel")?.querySelector(".rm-panel__footer"); const bottom = footer && getComputedStyle(footer).position === "sticky" ? Math.min(innerHeight, footer.getBoundingClientRect().top) : innerHeight; if (rect.top < document.querySelector(".rm-topbar").getBoundingClientRect().bottom || rect.bottom > bottom) { target.scrollIntoView({ block: "start", behavior: "instant" }); } } function syncView() { const order = STAGE_VIEWS[document.body.dataset.stage] ?? STAGE_VIEWS[1]; const next = order.find((id) => !element(id).hidden) ?? "ssh-panel"; const continueButton = element("selection-continue"); // Write only real changes: this runs from a MutationObserver on `hidden`. if (continueButton.hidden !== element("settings-panel").hidden) continueButton.hidden = element("settings-panel").hidden; if (next === currentView) return; const previous = element(currentView); const active = document.activeElement; for (const id of STAGE_VIEWS[3]) element(id).toggleAttribute("data-current", id === next); currentView = next; const heading = element(next).querySelector("h2"); if (active && previous.contains(active)) focusVisible(heading); else if (!active || active === document.body) focusVisible(heading); } async function readProjectMeta() { if (!token) throw new Error("This private wizard session is missing."); const response = await fetch("/api/local/project-meta", { headers: { "X-Setup-Token": token }, credentials: "omit", mode: "same-origin", redirect: "error", cache: "no-store", }); if (!response.ok) throw new Error("Relmio project details are unavailable."); return response.json(); } function formatElapsed(seconds) { return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; } function updateProgress() { const elapsed = Math.max(0, Math.floor((Date.now() - state.progressStartedAt) / 1_000)); element("operation-progress-elapsed").textContent = formatElapsed(elapsed); element("operation-progress-elapsed").setAttribute("datetime", `PT${elapsed}S`); } function startProgress(label) { state.progressStartedAt = Date.now(); element("operation-progress-label").textContent = label; element("operation-progress-bar").setAttribute("aria-valuetext", label); element("operation-progress").hidden = false; // "nearest" scrolls only when the card is out of view, so the startup check does not move the page. element("operation-progress").scrollIntoView?.({ block: "nearest", behavior: "instant" }); updateProgress(); state.progressTimer = window.setInterval(updateProgress, 1_000); } function stopProgress() { window.clearInterval(state.progressTimer); state.progressTimer = null; element("operation-progress").hidden = true; element("operation-progress-label").textContent = "No operation is running."; element("operation-progress-elapsed").textContent = "00:00"; element("operation-progress-elapsed").setAttribute("datetime", "PT0S"); element("operation-progress-bar").setAttribute("aria-valuetext", "No operation is running."); } function controls() { return [...document.querySelectorAll("button, input, select, textarea, summary, a[href]")] .filter((control) => !control.closest?.(".rm-guide-layer, [data-guide-help]")); } for (const name of ["click", "pointerdown", "keydown", "beforeinput", "input", "change", "submit"]) { document.addEventListener(name, (event) => { if (!state.busy || event.target?.closest?.(".rm-guide-layer, [data-guide-help]") || (name === "keydown" && event.key === "Tab")) return; event.preventDefault(); event.stopImmediatePropagation(); }, true); } async function perform(label, work) { if (state.busy) return undefined; state.busy = true; const origin = document.activeElement; clearError(); setMessage(label); document.body.dataset.operationBusy = "true"; element("route-steps-content").setAttribute("aria-busy", "true"); const snapshots = controls().map((control) => ({ control, disabled: typeof control.disabled === "boolean" ? control.disabled : null, tabIndex: control.getAttribute("tabindex"), })); for (const { control, disabled } of snapshots) { if (disabled !== null) control.disabled = true; else control.setAttribute("tabindex", "-1"); } startProgress(label); try { return await work(); } catch (error) { showError(error); return undefined; } finally { stopProgress(); for (const { control, disabled, tabIndex } of snapshots) { if (disabled !== null) control.disabled = disabled; else if (tabIndex === null) control.removeAttribute("tabindex"); else control.setAttribute("tabindex", tabIndex); } state.busy = false; document.body.dataset.operationBusy = "false"; element("route-steps-content").setAttribute("aria-busy", "false"); syncConnectionControls(); // Keyboard focus returns to the control that started this, unless an error or a new view took it. if ([document.body, origin].includes(document.activeElement) && origin?.isConnected && !origin.disabled && origin.getClientRects().length) { origin.focus({ preventScroll: true }); } } } async function api(path, body = {}) { if (!token) { throw new Error("This private wizard session is missing. Return to Relmio and open a fresh setup link."); } await sshSession.before(path); let response; try { response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json", "X-Setup-Token": token }, body: JSON.stringify(body), }); } catch { throw new Error("The local wizard server is not reachable. Keep its terminal open, then reopen Relmio."); } const result = await response.json().catch(() => null); if (!result || typeof result !== "object" || Array.isArray(result)) { throw new Error("The wizard returned an unreadable response. Start a fresh setup link."); } if (!response.ok) throw wizardApiError(result, response.status, "The operation could not be completed."); await sshSession.after(path, result); return result; } function selectedBoundary() { return { containerName: element("container").value, networkName: element("network").value }; } function boundaryKey(boundary = selectedBoundary()) { return `${boundary.containerName}\u0000${boundary.networkName}`; } function stopLoginPolling({ clearVisible = false } = {}) { state.loginGeneration += 1; window.clearTimeout(state.pollTimer); state.pollTimer = null; if (clearVisible) { element("login-panel").hidden = true; element("device-code").textContent = ""; element("device-link").hidden = true; element("device-link").removeAttribute("href"); } } function scheduleLoginPoll(installId, generation, delay = 2_000) { window.clearTimeout(state.pollTimer); state.pollTimer = null; if ( generation !== state.loginGeneration || state.status?.installId !== installId ) return; state.pollTimer = window.setTimeout(() => { state.pollTimer = null; // A hidden tab waits and checks once when it is shown again. whenVisible(() => void pollLogin(installId, generation)); }, delay); } function invalidateReview({ hide = true } = {}) { state.plan = null; element("confirm-action").checked = false; element("apply-button").disabled = true; if (hide) element("review-panel").hidden = true; } function invalidateBoundaryState() { state.requestGeneration += 1; invalidateReview(); stopLoginPolling({ clearVisible: true }); state.status = null; element("client-key").value = ""; element("settings-panel").hidden = true; element("models-result").textContent = ""; } function syncConnectionControls() { const trusted = Boolean(state.fingerprint && element("trust-host").checked); sshAuthentication.sync({ trusted }); element("connect-button").disabled = !trusted; element("apply-button").disabled = !state.plan || !element("confirm-action").checked; } function selectOptions(node, values) { node.replaceChildren(...values.map(([value, label]) => { const option = document.createElement("option"); option.value = value; option.textContent = label; return option; })); } function renderStatus(next) { if (state.status?.installId !== next.installId) { element("client-key").value = ""; element("models-result").textContent = ""; } state.status = next; const copy = next.state === "absent" ? "No Relmio-managed SuperGrok companion is installed for this VPS." : next.state === "healthy" ? "The private companion is healthy. Complete official Grok sign-in, then check your account models." : "An incomplete companion was detected. Review its removal before installing again."; element("installation-state").textContent = copy; element("settings-panel").hidden = next.state !== "healthy"; for (const button of document.querySelectorAll("[data-action]")) { const action = button.dataset.action; button.hidden = next.credentialActionRunning ? action !== "cancel-sign-in" : action === "cancel-sign-in" || (action === "install" ? next.state !== "absent" : action === "remove" ? next.state === "absent" : next.state !== "healthy"); } setStage(next.state === "healthy" ? 4 : 2); } async function refreshStatus() { invalidateReview(); sshSession.refreshHostReport(); const boundary = selectedBoundary(); if (!boundary.networkName) return; const requestGeneration = ++state.requestGeneration; const next = await api("/api/vps/supergrok/status", boundary); if (requestGeneration !== state.requestGeneration || boundaryKey(boundary) !== boundaryKey()) return; renderStatus(next); setMessage("VPS status checked. Existing n8n is unchanged."); } async function loadNetworks() { invalidateBoundaryState(); const containerName = element("container").value; const requestGeneration = ++state.requestGeneration; const result = await api("/api/networks", { containerName }); if (requestGeneration !== state.requestGeneration || containerName !== element("container").value) return; selectOptions(element("network"), result.networks.map((name) => [name, name])); if (result.networks.includes(result.recommended)) element("network").value = result.recommended; await refreshStatus(); } async function discover() { invalidateBoundaryState(); const requestGeneration = ++state.requestGeneration; const result = await api("/api/discover"); if (requestGeneration !== state.requestGeneration) return; if (!Array.isArray(result.containers) || result.containers.length === 0) { // The server says what it looked for when it has that sentence. throw new Error(typeof result.noneFoundMessage === "string" && result.noneFoundMessage.length <= 240 ? result.noneFoundMessage : "No running n8n container was found on this VPS."); } selectOptions(element("container"), result.containers.map((item) => [item.name, `${item.name} · ${item.image}`])); element("ssh-panel").hidden = true; element("selection-panel").hidden = false; setStage(2); await loadNetworks(); } function resetHost() { state.fingerprint = null; invalidateBoundaryState(); element("trust-host").checked = false; element("fingerprint-box").hidden = true; element("fingerprint").textContent = ""; element("password").value = ""; syncConnectionControls(); } async function prepareReview(action) { invalidateReview(); const boundary = selectedBoundary(); const reviewed = await api("/api/vps/supergrok/plan", { ...boundary, action }); if (reviewed.action !== action || reviewed.containerName !== boundary.containerName || reviewed.networkName !== boundary.networkName || reviewed.operationLockPath !== "/docker/n8n-openai-oauth/.supergrok-operation.lock" || reviewed.temporaryBuildStatePath !== (["install", "sign-in", "sign-out"].includes(action) ? "/docker/n8n-openai-oauth/.supergrok-operation.lock/buildx" : null)) { throw new Error("The reviewed SuperGrok build boundary is invalid. Review a fresh plan."); } if (boundaryKey(boundary) !== boundaryKey()) return; state.plan = reviewed; element("review-summary").textContent = `${actionLabels[reviewed.action]}. ${reviewed.action === "remove" ? "Its saved OAuth session will be deleted. Other companions and n8n data stay." : "No existing provider credentials will be copied."} Serializes with ${reviewed.operationLockPath}.`; if (reviewed.temporaryBuildStatePath) { element("review-summary").textContent += ` After you confirm, builds use a temporary root-only folder at ${reviewed.temporaryBuildStatePath}. If cleanup is uncertain, that folder and its lock may remain for you to inspect. Registry credentials are not copied or printed. Cleanup does not change n8n or model caches.`; } element("review-container").textContent = reviewed.containerName; element("review-network").textContent = reviewed.networkName; element("apply-button").textContent = actionLabels[reviewed.action]; element("review-panel").hidden = false; setStage(3); focusVisible(element("review-title")); setMessage("Review the action and confirm when ready."); } async function pollLogin(installId, generation) { if (generation !== state.loginGeneration) return; if (state.busy) { scheduleLoginPoll(installId, generation, 500); return; } try { const result = await api("/api/vps/supergrok/login-status", { installId }); if (generation !== state.loginGeneration || state.status?.installId !== installId) return; if (state.busy) { scheduleLoginPoll(installId, generation, 500); return; } element("login-panel").hidden = false; element("login-state").textContent = result.state === "pending" ? "Complete the sign-in on the official Grok page, then return here." : result.state === "complete" ? "The official Grok action completed. Check models to verify your account connection." : result.state === "expired" ? "This device code expired before sign-in completed. Start a fresh official Grok sign-in." : result.state === "cancelled" ? "This device sign-in was cancelled. Start a new official Grok sign-in when ready." : "The official Grok sign-in failed. Refresh status and start a new sign-in."; element("device-code").textContent = result.userCode || ""; element("device-link").hidden = !result.verificationUrl; if (result.verificationUrl) element("device-link").href = result.verificationUrl; if (result.state === "pending") { scheduleLoginPoll(installId, generation); } else { await perform("Refreshing account state…", refreshStatus); } } catch { if (generation === state.loginGeneration) setMessage("The sign-in status could not be refreshed. Reconnect if the SSH session expired."); } } for (const id of ["host", "port"]) element(id).addEventListener("input", () => { clearFieldError(element(id), "error-message"); clearError(); resetHost(); }); for (const id of ["username", "password"]) element(id).addEventListener("input", () => { clearFieldError(element(id), "error-message"); clearError(); }); element("ssh-form").addEventListener("invalid", (event) => { const label = { host: "Hostname", port: "Port", username: "Username", password: "Password" }[event.target.id]; if (!label) return; event.preventDefault(); showError(new Error(`${label} is invalid.`)); }, true); element("scan-button").addEventListener("click", () => { clearError(); for (const id of ["host", "port"]) { if (!element(id).checkValidity()) { showError(new Error(id === "host" ? "Hostname is invalid." : "Port is invalid.")); return; } } void perform("Checking VPS identity…", async () => { resetHost(); const result = await api("/api/ssh/fingerprint", { host: element("host").value, port: element("port").value }); for (const id of ["host", "port"]) clearFieldError(element(id), "error-message"); state.fingerprint = result.fingerprint; element("fingerprint").textContent = state.fingerprint; element("fingerprint-box").hidden = false; setMessage("Compare the fingerprint before authenticating."); }).then(() => { // As on the main setup page, a finished check hands focus to the confirmation. if (state.fingerprint && document.activeElement === element("scan-button")) element("trust-host").focus(); }); }); element("trust-host").addEventListener("change", () => { if (!element("trust-host").checked) element("password").value = ""; syncConnectionControls(); }); element("ssh-form").addEventListener("submit", (event) => { event.preventDefault(); if (!state.fingerprint || !element("trust-host").checked) return; void perform("Connecting and finding n8n…", async () => { await api("/api/ssh/connect", sshAuthentication.request(state.fingerprint)); // The confirmed identity has been used. Another attempt starts with a fresh check. resetHost(); for (const id of ["username", "password"]) clearFieldError(element(id), "error-message"); await discover(); }); }); element("container").addEventListener("change", () => void perform("Reading private networks…", loadNetworks)); element("network").addEventListener("change", () => void perform("Checking companion…", refreshStatus)); element("refresh-button").addEventListener("click", () => void perform("Checking companion…", refreshStatus)); for (const button of document.querySelectorAll("[data-action]")) { button.addEventListener("click", () => void perform("Preparing the review…", () => prepareReview(button.dataset.action))); } element("confirm-action").addEventListener("change", syncConnectionControls); element("cancel-review").addEventListener("click", () => { if (state.busy) return; invalidateReview(); setStage(2); focusVisible(element("selection-title")); setMessage("No companion action was applied."); }); element("apply-button").addEventListener("click", () => { if (!state.plan || !element("confirm-action").checked) return; void perform("Applying the reviewed companion action…", async () => { const reviewed = state.plan; invalidateReview(); const result = await api("/api/vps/supergrok/apply", { ...reviewed, confirmed: true }); await refreshStatus(); if (result.clientKey) element("client-key").value = result.clientKey; if (result.state === "pending") { stopLoginPolling(); const generation = ++state.loginGeneration; scheduleLoginPoll(result.installId, generation, 1_500); } if (result.state === "cancelled" || result.removed) stopLoginPolling({ clearVisible: true }); if (result.removed) element("client-key").value = ""; setMessage(result.removed ? "The SuperGrok companion was removed. Existing n8n is unchanged." : "The reviewed companion action completed. Existing n8n is unchanged."); }); }); element("models-button").addEventListener("click", () => { void perform("Checking your account models…", async () => { const result = await api("/api/vps/supergrok/models", { installId: state.status?.installId, clientKey: element("client-key").value }); element("models-result").textContent = result.status === 200 ? `Available models: ${result.models.join(", ")}` : result.code === "login_required" ? "Sign-in is required. Start an official Grok device sign-in for this companion." : "Account model discovery failed. Try again after checking your Grok account."; setMessage("Model check finished."); }); }); element("copy-key").addEventListener("click", async (event) => { const button = event.currentTarget; const input = element("client-key"); if (state.busy || !input.value) return; try { try { await navigator.clipboard.writeText(input.value); } catch { const fallback = document.createElement("textarea"); fallback.value = input.value; fallback.style.position = "fixed"; fallback.style.opacity = "0"; document.body.append(fallback); try { fallback.focus(); fallback.select(); if (!document.execCommand("copy")) throw new Error("Clipboard unavailable."); } finally { fallback.remove(); button.focus(); } } flashCopied(button); setMessage("Local bearer copied. Paste it directly into the n8n credential."); } catch { showError(new Error("Clipboard access was refused by the browser. Select the local bearer manually.")); } }); element("disconnect-button").addEventListener("click", () => { void perform("Disconnecting…", async () => { await api("/api/disconnect"); invalidateBoundaryState(); element("ssh-panel").hidden = false; element("selection-panel").hidden = true; resetHost(); setStage(1); setMessage("Disconnected. Your installed companion remains on the VPS."); }); }); window.addEventListener("pagehide", () => { stopLoginPolling(); element("client-key").value = ""; }); element("settings-back").addEventListener("click", () => { if (!state.busy) setStage(2); }); element("selection-continue").addEventListener("click", () => { if (!state.busy && !element("settings-panel").hidden) setStage(4); }); new MutationObserver(syncView).observe(element("main-content"), { subtree: true, attributeFilter: ["hidden"] }); initWizardTopbar({ session: token, isBusy: () => state.busy, loadProjectMeta: readProjectMeta }); const sshAuthentication = bindSshAuthentication({ token, trustId: "trust-host", onChange: invalidateBoundaryState, shouldApplyConnectionStatus: () => sshIdentityDecision === 0 }); if (!token) { setMessage("Open this page from the Relmio wizard to establish a private session."); } else { void perform("Checking the VPS connection…", async () => { try { await sshSession.adoptCurrent(); await discover(); } catch (error) { if (error.message !== "Connect to the VPS first.") throw error; element("ssh-panel").hidden = false; setStage(1); setMessage("Connect to your VPS to begin. No ChatGPT sign-in is required."); } }); }