import { bindWizardNavigation, readWizardSession, wizardApiError } from "./session.js"; import { bindSshAuthentication, clearFieldError, createCredentialSshGuard, markRejectedField } from "./ssh-form.js"; import { initWizardTopbar } from "./topbar.js"; const token = readWizardSession(); const state = { fingerprint: null, network: null, reviewedIncludeSearxng: null, installAttempted: false, planId: null, operationBusy: false, operationAllowedSelector: null, operationButton: null, operationButtonAriaBusy: null, operationButtonLabel: "", operationControlObserver: null, operationControlStates: [], operationFocusControl: null, operationLabel: "", operationMessageLive: null, operationOwner: 0, operationProgressStartedAt: 0, operationProgressTimer: null, }; const element = (id) => document.getElementById(id); const message = element("global-message-text"); const errorBox = element("global-error"); const errorMessage = element("global-error-text"); let sshIdentityDecision = 0; const sshAuthentication = bindSshAuthentication({ token, trustId: "fingerprint-confirm", onChange: invalidateReviewedPlan, shouldApplyConnectionStatus: () => sshIdentityDecision === 0 }); const hostReport = () => ({ flow: "assistant", container: element("container-select").value, network: element("network-select").value }); const sshSession = createCredentialSshGuard({ token, hostReport, onIdentityDecision() { sshIdentityDecision++; }, onMismatch() { invalidateReviewedPlan(); state.network = null; resetFingerprint(); element("container-select").replaceChildren(); element("network-select").replaceChildren(); showStep(1); } }); function focusVisible(target) { target.focus({ preventScroll: true }); target.scrollIntoView?.({ block: "start", behavior: "instant" }); } function setMessage(value) { message.textContent = value; } function clearError() { errorBox.hidden = true; errorMessage.textContent = ""; globalThis.relmioGuide?.clearError?.(); } function showError(error) { const text = markRejectedField(error?.message ?? "The request could not be completed.", element, "global-error-text"); errorMessage.textContent = text; errorBox.hidden = false; errorBox.focus(); globalThis.relmioGuide?.error?.(error); } function validatePlanId(value) { if ( typeof value !== "string" || value.length === 0 || value.length > 512 || /[\0\r\n]/u.test(value) ) { throw new Error("The wizard returned an unexpected plan reference."); } return value; } function invalidateReviewedPlan() { state.planId = null; state.reviewedIncludeSearxng = null; element("install-confirm").checked = false; element("install-button").disabled = true; } const OPERATION_INTERACTIVE_SELECTOR = 'button, input, select, textarea, summary, a[href], [contenteditable]'; const OPERATION_BLOCKED_EVENTS = [ "click", "pointerdown", "keydown", "beforeinput", "input", "change", "submit", ]; const OPERATION_DEFAULT_NOTE = "Timing varies with your remote server and network. Keep this page open until this step finishes."; function readOperationAttribute(control, name) { return typeof control.getAttribute === "function" ? control.getAttribute(name) : control.attributes?.get?.(name) ?? null; } function restoreOperationAttribute(control, name, value) { if (value === null) { if (typeof control.removeAttribute === "function") { control.removeAttribute(name); } else { control.attributes?.delete?.(name); } return; } control.setAttribute?.(name, value); } function isOperationAllowedControl(control) { return Boolean( control?.closest?.(".rm-guide-layer button, .rm-guide-layer select, .rm-guide-layer summary, [data-guide-help]") || state.operationAllowedSelector && control?.closest?.(state.operationAllowedSelector), ); } function operationControlCandidates() { return Array.from( document.querySelectorAll?.(OPERATION_INTERACTIVE_SELECTOR) ?? [], ); } function lockOperationControl(control) { if (!control || isOperationAllowedControl(control)) return; const existingSnapshot = state.operationControlStates.find( (snapshot) => snapshot.control === control, ); if (existingSnapshot) { if (existingSnapshot.disabled !== null && control.disabled !== true) { control.disabled = true; } if (existingSnapshot.readOnly !== null && control.readOnly !== true) { control.readOnly = true; } if (readOperationAttribute(control, "aria-disabled") !== "true") { control.setAttribute?.("aria-disabled", "true"); } if ( existingSnapshot.disabled === null && readOperationAttribute(control, "tabindex") !== "-1" ) { control.setAttribute?.("tabindex", "-1"); } if ( existingSnapshot.contentEditable !== null && readOperationAttribute(control, "contenteditable") !== "false" ) { control.setAttribute?.("contenteditable", "false"); } return; } const snapshot = { control, disabled: typeof control.disabled === "boolean" ? control.disabled : null, readOnly: typeof control.readOnly === "boolean" ? control.readOnly : null, ariaDisabled: readOperationAttribute(control, "aria-disabled"), tabIndex: readOperationAttribute(control, "tabindex"), contentEditable: readOperationAttribute(control, "contenteditable"), }; state.operationControlStates.push(snapshot); if (snapshot.disabled !== null) control.disabled = true; if (snapshot.readOnly !== null) control.readOnly = true; control.setAttribute?.("aria-disabled", "true"); if (snapshot.disabled === null) control.setAttribute?.("tabindex", "-1"); if (snapshot.contentEditable !== null) { control.setAttribute?.("contenteditable", "false"); } } function lockAddedOperationControls(node) { if (!node || node.nodeType !== 1) return; if (node.matches?.(OPERATION_INTERACTIVE_SELECTOR)) { lockOperationControl(node); } for (const control of node.querySelectorAll?.( OPERATION_INTERACTIVE_SELECTOR, ) ?? []) { lockOperationControl(control); } } function formatOperationElapsed(elapsedSeconds) { const minutes = String(Math.floor(elapsedSeconds / 60)).padStart(2, "0"); const seconds = String(elapsedSeconds % 60).padStart(2, "0"); return `${minutes}:${seconds}`; } function updateOperationProgress() { const elapsedSeconds = Math.max( 0, Math.floor((Date.now() - state.operationProgressStartedAt) / 1_000), ); const elapsed = formatOperationElapsed(elapsedSeconds); const elapsedElement = element("operation-progress-elapsed"); elapsedElement.textContent = elapsed; elapsedElement.setAttribute("datetime", `PT${elapsedSeconds}S`); } function updateOperationLabel(label) { state.operationLabel = label || "Working…"; element("operation-progress-label").textContent = state.operationLabel; element("operation-progress-bar").setAttribute( "aria-valuetext", state.operationLabel, ); if (state.operationButton) { state.operationButton.textContent = state.operationLabel; } updateOperationProgress(); } function syncPanelBusy() { for (const panel of document.querySelectorAll(".assistant-step")) { panel.setAttribute("aria-busy", String(state.operationBusy && !panel.hidden)); } } function startOperation( trigger, label, { allowedSelector = null, progressNote = OPERATION_DEFAULT_NOTE, } = {}, ) { if (state.operationBusy) return false; state.operationBusy = true; state.operationAllowedSelector = allowedSelector; state.operationOwner += 1; state.operationFocusControl = trigger ?? (document.activeElement && document.activeElement !== document.body ? document.activeElement : element("main-content")); state.operationButton = trigger?.tagName === "BUTTON" ? trigger : null; state.operationButtonLabel = state.operationButton?.textContent?.trim?.() ?? ""; state.operationButtonAriaBusy = state.operationButton ? readOperationAttribute(state.operationButton, "aria-busy") : null; state.operationControlStates = []; state.operationLabel = label || "Working…"; state.operationProgressStartedAt = Date.now(); if (state.operationButton) { state.operationButton.setAttribute("aria-busy", "true"); state.operationButton.textContent = state.operationLabel; } for (const control of operationControlCandidates()) { lockOperationControl(control); } document.body.dataset.operationBusy = "true"; syncPanelBusy(); const messageRegion = element("global-message"); state.operationMessageLive = readOperationAttribute( messageRegion, "aria-live", ); messageRegion.setAttribute("aria-live", "off"); element("operation-progress-note").textContent = progressNote; const progress = element("operation-progress"); progress.hidden = false; updateOperationLabel(state.operationLabel); focusVisible(progress); if (typeof MutationObserver !== "undefined" && document.body) { state.operationControlObserver = new MutationObserver((records) => { if (!state.operationBusy) return; for (const record of records) { if (record.type === "attributes") { lockAddedOperationControls(record.target); } for (const node of record.addedNodes ?? []) { lockAddedOperationControls(node); } } }); state.operationControlObserver.observe(document.body, { attributeFilter: ["disabled", "readonly", "href", "contenteditable"], attributes: true, childList: true, subtree: true, }); } state.operationProgressTimer = window.setInterval( updateOperationProgress, 1_000, ); return true; } function stopOperation(trigger, expectedOwner) { if ( !state.operationBusy || (expectedOwner !== undefined && expectedOwner !== state.operationOwner) ) { return false; } if (state.operationProgressTimer !== null) { window.clearInterval(state.operationProgressTimer); } state.operationProgressTimer = null; state.operationControlObserver?.disconnect?.(); state.operationControlObserver = null; state.operationBusy = false; for (const snapshot of state.operationControlStates) { if (snapshot.disabled !== null) { snapshot.control.disabled = snapshot.disabled; } if (snapshot.readOnly !== null) { snapshot.control.readOnly = snapshot.readOnly; } restoreOperationAttribute( snapshot.control, "aria-disabled", snapshot.ariaDisabled, ); restoreOperationAttribute(snapshot.control, "tabindex", snapshot.tabIndex); restoreOperationAttribute( snapshot.control, "contenteditable", snapshot.contentEditable, ); } state.operationControlStates = []; document.body.dataset.operationBusy = "false"; syncPanelBusy(); restoreOperationAttribute( element("global-message"), "aria-live", state.operationMessageLive, ); const progress = element("operation-progress"); const restoreFocus = document.activeElement === progress || progress.contains?.(document.activeElement) || isOperationAllowedControl(document.activeElement); progress.hidden = true; element("operation-progress-label").textContent = "No operation is running."; const elapsedElement = element("operation-progress-elapsed"); elapsedElement.textContent = "00:00"; elapsedElement.setAttribute("datetime", "PT0S"); element("operation-progress-bar").setAttribute( "aria-valuetext", "No operation is running.", ); element("operation-progress-note").textContent = OPERATION_DEFAULT_NOTE; const activeButton = state.operationButton; if (activeButton) { restoreOperationAttribute( activeButton, "aria-busy", state.operationButtonAriaBusy, ); activeButton.textContent = state.operationButtonLabel; } const focusControl = state.operationFocusControl ?? trigger ?? null; if ( restoreFocus && focusControl?.isConnected !== false && !focusControl?.disabled && !focusControl?.hidden && !focusControl?.closest?.("[hidden]") ) { focusVisible(focusControl); } state.operationButton = null; state.operationAllowedSelector = null; state.operationButtonAriaBusy = null; state.operationButtonLabel = ""; state.operationFocusControl = null; state.operationLabel = ""; state.operationMessageLive = null; state.operationProgressStartedAt = 0; return true; } async function runOperation(trigger, label, work, options) { if (!startOperation(trigger, label, options)) return undefined; const operationOwner = state.operationOwner; try { return await work(); } finally { stopOperation(trigger, operationOwner); } } async function runActiveOperationTask(button, label, work) { if (!state.operationBusy || !isOperationAllowedControl(button)) { return undefined; } const operationOwner = state.operationOwner; const previousLabel = state.operationLabel; const previousButtonLabel = button.textContent; const previousDisabled = button.disabled; const previousAriaBusy = readOperationAttribute(button, "aria-busy"); updateOperationLabel(label); button.disabled = true; button.setAttribute("aria-busy", "true"); button.textContent = label; try { return await work(); } finally { button.disabled = previousDisabled; restoreOperationAttribute(button, "aria-busy", previousAriaBusy); button.textContent = previousButtonLabel; if ( state.operationBusy && state.operationOwner === operationOwner ) { updateOperationLabel(previousLabel); } } } function blockOperationInteraction(event) { if ( !state.operationBusy || (event.type === "keydown" && event.key === "Tab") || (event.type === "keydown" && event.key === "Escape" && event.target?.closest?.(".rm-guide-layer")) || event.target?.closest?.("#operation-progress") || isOperationAllowedControl(event.target) ) { return; } event.preventDefault?.(); event.stopImmediatePropagation?.(); } for (const eventName of OPERATION_BLOCKED_EVENTS) { document.addEventListener(eventName, blockOperationInteraction, true); } function showStep(step) { document.body.dataset.currentStep = String(step); let activePanel = null; for (const panel of document.querySelectorAll("[data-step]")) { const active = Number(panel.dataset.step) === step; panel.hidden = !active; if (active) activePanel = panel; } syncPanelBusy(); for (const marker of document.querySelectorAll("[data-step-marker]")) { const markerStep = Number(marker.dataset.stepMarker); if (markerStep === step) marker.setAttribute("aria-current", "step"); else marker.removeAttribute("aria-current"); if (markerStep < step) marker.dataset.state = "done"; else delete marker.dataset.state; } if (activePanel && activePanel.getBoundingClientRect().top < 0) { activePanel.scrollIntoView({ block: "start" }); } activePanel?.querySelector("h2")?.focus({ preventScroll: true }); } function resetFingerprint() { invalidateReviewedPlan(); state.fingerprint = null; element("fingerprint-box").hidden = true; element("fingerprint-confirm").checked = false; element("password").value = ""; element("password").disabled = true; updateConnectState(); } function updateConnectState() { element("connect-button").disabled = !( state.fingerprint && element("fingerprint-confirm").checked && element("privileged-confirm").checked ); } function renderFingerprint(fingerprint) { resetFingerprint(); state.fingerprint = fingerprint; element("fingerprint-value").textContent = fingerprint; element("fingerprint-box").hidden = false; } function fillSelect(select, items, selectedValue) { select.replaceChildren(); for (const item of items) { const option = document.createElement("option"); option.value = item.value; option.textContent = item.label; option.selected = item.value === selectedValue; select.append(option); } } function formatInstanceAiStatus(instanceAi) { switch (instanceAi?.status) { case "enabled": return "AI Assistant is enabled: N8N_ENABLED_MODULES includes instance-ai. Refresh discovery after n8n changes."; case "configured": return "instance-ai is missing from N8N_ENABLED_MODULES. Add it as its own comma-delimited value, keep existing values, then redeploy or restart n8n yourself."; case "missing": return "Add N8N_ENABLED_MODULES=instance-ai to existing n8n, then redeploy or restart n8n yourself."; default: return "AI Assistant prerequisite could not be verified for this n8n container."; } } async function api(path, { method = "GET", body } = {}) { if (!token) throw new Error("The setup session is missing. Start the assistant command again."); await sshSession.before(path); const response = await fetch(path, { method, headers: { "Content-Type": "application/json", "X-Setup-Token": token, }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); let result; try { result = await response.json(); } catch { throw new Error("The wizard returned an unreadable response. Start again."); } if (!response.ok) throw wizardApiError(result, response.status, "The request failed."); await sshSession.after(path, result); return result; } async function loadNetworks( containerName = element("container-select").value, ) { return api("/api/networks", { method: "POST", body: { containerName }, }); } function renderNetworks(result) { state.network = result; const instanceAiStatus = element("instance-ai-status"); instanceAiStatus.textContent = formatInstanceAiStatus(result.instanceAi); instanceAiStatus.dataset.status = result.instanceAi?.status ?? "unknown"; const prerequisiteReady = result.instanceAi?.status === "enabled"; element("review-button").disabled = !prerequisiteReady; element("review-readiness").textContent = prerequisiteReady ? "Ready to review. Nothing has been written." : "Enable instance-ai, restart n8n yourself, then reconnect before you review."; fillSelect( element("network-select"), result.networks.map((network) => ({ value: network, label: network })), result.recommended, ); sshSession.refreshHostReport(); } async function discover() { const discovery = await api("/api/discover", { method: "POST", body: {} }); if ( !Array.isArray(discovery.containers) || discovery.containers.length === 0 ) { // The server says what it looked for when it has that sentence. throw new Error(typeof discovery.noneFoundMessage === "string" && discovery.noneFoundMessage.length <= 240 ? discovery.noneFoundMessage : "No running official n8n container was found."); } const networks = await loadNetworks(discovery.containers[0].name); return { discovery, networks }; } function renderDiscovery({ discovery, networks }) { fillSelect( element("container-select"), discovery.containers.map((container) => ({ value: container.name, label: `${container.name} - ${container.image}`, })), discovery.containers[0].name, ); renderNetworks(networks); showStep(2); } element("fingerprint-button").addEventListener("click", async (event) => { const button = event.currentTarget; clearError(); invalidateReviewedPlan(); for (const id of ["host", "port"]) { if (!element(id).checkValidity()) { showError(new Error(id === "host" ? "Hostname is invalid." : "Port is invalid.")); return; } } try { const result = await runOperation( button, "Checking server identity…", () => api("/api/ssh/fingerprint", { method: "POST", body: { host: element("host").value, port: element("port").value }, }), ); if (!result) return; for (const id of ["host", "port"]) clearFieldError(element(id), "global-error-text"); renderFingerprint(result.fingerprint); setMessage("Confirm the SSH host identity before authenticating."); // As on the main setup page, a finished check hands focus to the confirmation. if (document.activeElement === button) focusVisible(element("fingerprint-confirm")); } catch (error) { showError(error); } }); element("fingerprint-confirm").addEventListener("change", (event) => { invalidateReviewedPlan(); sshAuthentication.sync({ trusted: event.currentTarget.checked }); if (!event.currentTarget.checked) element("password").value = ""; updateConnectState(); }); element("privileged-confirm").addEventListener("change", () => { invalidateReviewedPlan(); updateConnectState(); }); element("host").addEventListener("input", () => { clearFieldError(element("host"), "global-error-text"); clearError(); resetFingerprint(); }); element("port").addEventListener("input", () => { clearFieldError(element("port"), "global-error-text"); clearError(); resetFingerprint(); }); element("username").addEventListener("input", () => { clearFieldError(element("username"), "global-error-text"); clearError(); }); element("password").addEventListener("input", () => { clearFieldError(element("password"), "global-error-text"); clearError(); invalidateReviewedPlan(); }); element("vps-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("vps-form").addEventListener("submit", async (event) => { event.preventDefault(); const button = element("connect-button"); clearError(); invalidateReviewedPlan(); setMessage("Connecting, then inspecting Docker with read-only commands…"); try { const discovered = await runOperation( button, "Wait…", async () => { await api("/api/ssh/connect", { method: "POST", body: sshAuthentication.request(state.fingerprint), }); return discover(); }, { progressNote: "Relmio is opening the verified SSH connection and inspecting Docker with read-only commands. Timing varies with your remote server and network. Keep this page open.", }, ); if (!discovered) return; element("password").value = ""; for (const id of ["username", "password"]) clearFieldError(element(id), "global-error-text"); renderDiscovery(discovered); setMessage("n8n was found. Choose an existing Docker network."); } catch (error) { element("password").value = ""; showError(error); } }); element("container-select").addEventListener("change", async (event) => { const select = event.currentTarget; const containerName = select.value; clearError(); invalidateReviewedPlan(); try { const networks = await runOperation( select, "Refreshing Docker networks…", () => loadNetworks(containerName), ); if (!networks) return; renderNetworks(networks); setMessage("Docker networks refreshed for the selected n8n container."); } catch (error) { showError(error); } }); element("network-select").addEventListener("change", () => { invalidateReviewedPlan(); sshSession.refreshHostReport(); setMessage("Network changed. Review a fresh plan before installing."); }); element("review-button").addEventListener("click", async (event) => { const button = event.currentTarget; clearError(); invalidateReviewedPlan(); try { const networkName = element("network-select").value; const includeSearxng = element("include-searxng").checked; const plan = await runOperation( button, "Preparing the exact plan…", () => api("/api/assistant/plan", { method: "POST", body: { containerName: element("container-select").value, networkName, includeSearxng, }, }), ); if (!plan) return; const planId = validatePlanId(plan.planId); state.reviewedIncludeSearxng = plan.includeSearxng; element("review-network").textContent = networkName; element("review-instance-ai").textContent = formatInstanceAiStatus(plan.instanceAi); element("review-web-search").textContent = plan.includeSearxng ? `Attach optional SearXNG web search to ${networkName} with its private alias` : "Web search is off. No SearXNG service, settings file, or URL will be installed."; // Plain sentences from the server, such as an n8n that already runs its own sandbox. const warnings = (Array.isArray(plan.warnings) ? plan.warnings : []) .filter((text) => typeof text === "string" && text && text.length <= 240); element("plan-warnings-list").replaceChildren(...warnings.map((text) => { const item = document.createElement("li"); item.textContent = text; return item; })); element("plan-warnings").hidden = warnings.length === 0; state.planId = planId; element("install-confirm").checked = false; element("install-button").disabled = true; showStep(3); setMessage("Review this privileged companion plan. The SSH host has not changed."); } catch (error) { showError(error); } }); element("install-confirm").addEventListener("change", (event) => { element("install-button").disabled = !( event.currentTarget.checked && state.planId ); }); element("include-searxng").addEventListener("change", () => { invalidateReviewedPlan(); setMessage("Web search choice changed. Review a fresh plan before installing."); }); element("install-button").addEventListener("click", async (event) => { const button = event.currentTarget; clearError(); if (!state.planId || !element("install-confirm").checked) { invalidateReviewedPlan(); showError(new Error("Review and confirm a fresh Assistant plan first.")); return; } state.installAttempted = true; try { const result = await runOperation( button, "Starting the companion…", () => api("/api/assistant/install", { method: "POST", body: { containerName: element("container-select").value, networkName: element("network-select").value, includeSearxng: state.reviewedIncludeSearxng, confirmed: element("install-confirm").checked, planId: state.planId, }, }), { progressNote: "Docker may be downloading images, building, or starting the reviewed companion. Timing varies with your remote server and network. Keep this page open until verification finishes.", }, ); if (!result) return; invalidateReviewedPlan(); element("sandbox-url").textContent = result.sandboxUrl; element("sandbox-api-key").textContent = typeof result.sandboxApiKey === "string" ? result.sandboxApiKey : "Unchanged on this managed update; the key is not redisplayed."; if (result.includeSearxng === true && typeof result.searxngUrl === "string") { element("searxng-url").textContent = result.searxngUrl; element("searxng-note").textContent = "SearXNG keeps its server secret private. There is no SearXNG key for you to enter."; } else { element("searxng-url").textContent = "Web search disabled"; element("searxng-note").textContent = "Web search is off, so no SearXNG service or settings file was installed. Its retained secret stays private."; } showStep(4); setMessage("Companion verified. Enter these values in n8n’s AI Assistant settings."); } catch (error) { invalidateReviewedPlan(); showError(error); } }); bindWizardNavigation(element("setup-another-assistant"), "/assistant", token); for (const button of document.querySelectorAll(".back-button")) { button.addEventListener("click", () => { clearError(); invalidateReviewedPlan(); showStep(Number(button.dataset.back)); setMessage("No new assistant installation has started."); }); } initWizardTopbar({ session: token, isBusy: () => state.operationBusy, loadProjectMeta: () => api("/api/local/project-meta"), });