import { createHostingArchive } from "./hosting-archive.js"; import { bindWizardNavigation, readWizardSession, wizardApiError } from "./session.js"; import { initWizardTopbar } from "./topbar.js"; const token = readWizardSession(); const el = (id) => document.getElementById(id); const COMPONENTS = Object.freeze({ model: "Local model", openai: "OpenAI bridge", assistant: "AI Assistant", searxng: "SearXNG", supergrok: "SuperGrok", claude: "Claude (Experimental)", endpoint: "External endpoint", }); const MANAGED_ROUTES = Object.freeze({ "managed-vm": { model: "/local-model-vps", openai: "/", assistant: "/assistant", searxng: "/assistant", supergrok: "/supergrok-vps", claude: "/claude-vps" }, local: { model: "/local", openai: "/local", assistant: "/local", searxng: "/local", supergrok: "/local", claude: "/local" }, }); const KIND_LABELS = Object.freeze({ "managed-vm": "Linux VMs", local: "Local Docker", "manual-platform": "Container platforms", "external-only": "External services", }); const MODE_LABELS = Object.freeze({ managed: "Managed", manual: "Manual", external: "External", unavailable: "None" }); // Managed server components that only a direct root sign-in can set up. const ROOT_ONLY_COMPONENTS = Object.freeze(["openai", "assistant", "searxng", "supergrok", "claude"]); const FILE_NAME = /^(?:[A-Za-z0-9][A-Za-z0-9._-]*|\.dockerignore)(?:\/(?:[A-Za-z0-9][A-Za-z0-9._-]*|\.dockerignore))*$/u; const state = { providers: [], profiles: [], models: [], generation: 0, plan: null, archive: null, requestPending: false }; const VIEW_HEADINGS = Object.freeze({ choose: "matrix-title", details: "details-title", review: "review-title" }); 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 showView(view) { for (const name of Object.keys(VIEW_HEADINGS)) el(`hosting-${name}`).hidden = name !== view; document.body.dataset.hostingView = view; focusVisible(el(VIEW_HEADINGS[view])); } function node(tag, text, className) { const item = document.createElement(tag); if (text !== undefined) item.textContent = text; if (className) item.className = className; return item; } // Takes a sentence or a failed request's error. The guide gets the error // itself, so its help can use the status and code. function error(failure) { const message = typeof failure === "string" ? failure : failure.message; el("hosting-error").textContent = message; el("hosting-error").hidden = false; el("hosting-error").focus(); globalThis.relmioGuide?.error?.(typeof failure === "string" ? { message } : failure); } function clearError() { el("hosting-error").textContent = ""; el("hosting-error").hidden = true; globalThis.relmioGuide?.clearError?.(); } function invalidate() { if (state.plan || state.requestPending) { el("hosting-status").textContent = "Selection changed. Review a new plan before downloading."; } state.generation++; state.plan = null; state.archive = null; state.requestPending = false; el("hosting-review").hidden = true; el("hosting-review-details").open = false; el("hosting-files").replaceChildren(); el("hosting-generate").disabled = !activeProfile(); el("hosting-continue").disabled = !activeProfile(); clearError(); } function activeProvider() { return state.providers.find((provider) => provider.id === el("hosting-provider").value) ?? null; } function activeProfile() { const provider = activeProvider(); const component = el("hosting-component").value; return provider && provider.components?.[component]?.mode === "manual" ? state.profiles.find((profile) => profile.providerId === provider.id && profile.component === component) ?? null : null; } function option(value, label) { const entry = node("option", label); entry.value = value; return entry; } const MODE_ORDER = Object.keys(MODE_LABELS); const MODE_ICONS = Object.freeze({ managed: "check-circle", manual: "download", external: "external" }); function modeOf(provider, component) { return provider.components?.[component]?.mode ?? "unavailable"; } function modeBadge(mode) { return node("span", MODE_LABELS[mode] ?? mode, mode === "managed" ? "rm-badge rm-badge--accent" : "rm-badge"); } function experimentalBadge() { return node("span", "Experimental", "rm-badge rm-badge--accent"); } // A short warning for a managed server component that needs a direct root // sign-in on a provider whose documented default account is not root. It only // informs: nothing here changes a login, authentication or privilege choice. // Keep it short enough to share the first line with the Managed badge: a // longer note wraps and makes step 1 scroll at 1280 x 720. function rootSignInNote(provider, component) { if (provider?.kind !== "managed-vm" || modeOf(provider, component) !== "managed" || !ROOT_ONLY_COMPONENTS.includes(component)) return null; if (provider.rootLogin === "off-by-default") return "No root sign-in by default."; if (provider.rootLogin === "operator-chosen") return "Root sign-in may be off."; return null; } // Compact cell: one icon per mode (shapes are explained by #hosting-legend), // with the mode name as visually hidden text for screen readers. function modeMark(mode) { const mark = node("span", undefined, "hosting-mark"); mark.dataset.mode = mode; if (MODE_ICONS[mode]) { const icon = node("span", undefined, `rm-icon rm-icon--${MODE_ICONS[mode]} rm-icon--sm`); icon.setAttribute("aria-hidden", "true"); mark.append(icon); } mark.append(node("span", MODE_LABELS[mode] ?? mode, "rm-visually-hidden")); return mark; } function modeCell(modes) { const cell = node("td"); cell.dataset.mode = modes.length === 1 ? modes[0] : "varies"; if (modes.length > 1) cell.append(node("span", "Varies: ", "rm-visually-hidden")); modes.forEach((mode, index) => { if (index > 0) cell.append(node("span", " or ", "rm-visually-hidden")); cell.append(modeMark(mode)); }); return cell; } function matrixRow(label, modes, { count = 0, selected = false, experimental = false } = {}) { const row = node("tr"); row.dataset.selected = String(selected); const heading = node("th"); heading.scope = "row"; if (selected) heading.append(node("span", "Selected: ", "rm-visually-hidden")); heading.append(node("span", label)); if (count > 1) { heading.append(" ", node("span", String(count), "hosting-matrix__count"), node("span", " hosts", "rm-visually-hidden")); } if (experimental) heading.append(" ", experimentalBadge()); row.append(heading, ...modes.map(modeCell)); return row; } function providersByKind() { const groups = new Map(); for (const provider of state.providers) { if (!groups.has(provider.kind)) groups.set(provider.kind, []); groups.get(provider.kind).push(provider); } return groups; } // One row per hosting type keeps the comparison on one screen. The chosen // provider gets its own row under its type with its exact availability. function renderMatrix() { const selectedId = el("hosting-provider").value; const rows = []; for (const [kind, providers] of providersByKind()) { const single = providers.length === 1; const selected = providers.find((provider) => provider.id === selectedId) ?? null; const modes = Object.keys(COMPONENTS).map((component) => { const found = new Set(providers.map((provider) => modeOf(provider, component))); return MODE_ORDER.filter((mode) => found.has(mode)); }); rows.push(matrixRow(single ? providers[0].label : KIND_LABELS[kind] ?? kind, modes, { count: providers.length, selected: single && Boolean(selected), experimental: providers.every((provider) => !provider.tested), })); if (selected && !single) { rows.push(matrixRow(selected.label, Object.keys(COMPONENTS).map((component) => [modeOf(selected, component)]), { selected: true, experimental: !selected.tested })); } } el("hosting-matrix").replaceChildren(...rows); } function renderSources(target, sources) { const links = sources.filter((source) => { try { return new URL(source.url).protocol === "https:"; } catch { return false; } }).map((source) => { const link = node("a", source.label, "rm-link rm-link--standalone"); link.href = source.url; link.target = "_blank"; link.rel = "noopener noreferrer"; const icon = node("span", undefined, "rm-icon rm-icon--external rm-icon--xs"); icon.setAttribute("aria-hidden", "true"); link.append(icon, node("span", " (opens in a new tab)", "rm-visually-hidden")); return link; }); target.replaceChildren(...links); } function renderProvider() { invalidate(); const provider = activeProvider(); renderMatrix(); el("hosting-component").replaceChildren(option("", "Choose what to set up")); el("hosting-component").disabled = !provider; if (provider) { for (const [component, label] of Object.entries(COMPONENTS)) { if (provider.components?.[component]) el("hosting-component").append(option(component, label)); } } el("hosting-provider-experimental").hidden = !provider || provider.tested === true; el("hosting-hint").textContent = provider ? `${provider.sshHint} Catalog reviewed ${provider.reviewedOn}; runtime NOT-RUN.` : "Choose hosting to see what it supports."; renderSources(el("hosting-sources"), provider?.sources ?? []); renderComponent(); } function renderComponent() { invalidate(); const provider = activeProvider(); const component = el("hosting-component").value; const capability = provider?.components?.[component]; const profile = activeProfile(); el("hosting-claude-details").hidden = component !== "claude"; el("hosting-claude-details").open = false; if (capability) { const rootNote = rootSignInNote(provider, component); el("hosting-capability").replaceChildren(modeBadge(capability.mode), " ", ...(rootNote ? [node("strong", rootNote, "hosting-capability__root"), " "] : []), node("span", capability.note)); } else { el("hosting-capability").textContent = "Choose what to set up to see what's available."; } const route = provider && MANAGED_ROUTES[provider.kind]?.[component]; const oldLink = el("hosting-managed-link"); const managedLink = oldLink.cloneNode(false); oldLink.replaceWith(managedLink); managedLink.hidden = !(capability?.mode === "managed" && route); if (!managedLink.hidden) { bindWizardNavigation(managedLink, route, token); managedLink.textContent = "Open managed setup"; } el("hosting-continue").hidden = !managedLink.hidden; el("hosting-model-wrap").hidden = !(profile && component === "model"); el("hosting-model").disabled = el("hosting-model-wrap").hidden; el("hosting-model").required = !el("hosting-model-wrap").hidden; el("hosting-model").replaceChildren( option("", "Choose an approved model"), ...state.models.map((model) => option(model.id, `${model.label} · ${model.quantization}`)), ); el("hosting-inputs").replaceChildren(...(profile?.fields ?? []).map(renderField)); el("hosting-no-inputs").hidden = !profile || profile.fields.length > 0 || component === "model"; el("hosting-limits").textContent = profile ? "Check provider access, private routing, resources, storage, and your existing n8n connection before using these files." : capability?.mode === "managed" ? provider.kind === "managed-vm" ? "Open setup to verify the server fingerprint (its identity) before connecting, then confirm the exact plan before any remote writes." : "Open local setup to review the plan and confirm before any changes." : capability ? "No complete manual plan is available for this route." : ""; el("hosting-generate").disabled = !profile; el("hosting-continue").disabled = !profile; } function renderField(field, index) { const id = `hosting-input-${index}`; const wrapper = node("div", undefined, "rm-field"); const label = node("label", field.label, "rm-field__label"); label.htmlFor = id; const input = document.createElement(field.type === "select" ? "select" : "input"); input.id = id; input.className = field.type === "select" ? "rm-select" : "rm-input"; input.name = field.name; input.required = field.required === true; if (field.type === "select") { if (field.defaultValue === undefined) input.append(option("", field.required ? "Choose an option" : "Not specified")); for (const choice of field.options ?? []) input.append(option(choice.value, choice.label)); if (field.defaultValue !== undefined) input.value = String(field.defaultValue); } else { input.type = field.type === "number" ? "number" : "text"; input.autocomplete = "off"; if (field.pattern) input.pattern = field.pattern; if (field.min !== undefined) input.min = String(field.min); if (field.max !== undefined) input.max = String(field.max); if (field.defaultValue !== undefined) input.value = String(field.defaultValue); } wrapper.append(label, input); if (field.description) { const hint = node("p", field.description, "rm-field__hint"); hint.id = `${id}-hint`; input.setAttribute("aria-describedby", hint.id); wrapper.append(hint); } return wrapper; } function randomDeploymentId() { const bytes = new Uint8Array(6); crypto.getRandomValues(bytes); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } function planInputs(profile) { const values = {}; for (const field of profile.fields) { const input = [...el("hosting-inputs").querySelectorAll("[name]")].find((item) => item.name === field.name); if (!input) throw new Error("The selected input is no longer available."); const value = input.value.trim(); if (value !== "") { const choice = field.type === "select" ? field.options?.find((entry) => String(entry.value) === value) : null; values[field.name] = field.type === "number" || typeof choice?.value === "number" ? Number(value) : value; } } return values; } function appendList(id, items) { el(id).replaceChildren(...items.map((item) => node("li", item))); } function appendFacts(target, entries) { target.replaceChildren(...entries.flatMap(([name, value]) => { const description = node("dd"); description.append(...[value].flat()); return [node("dt", name), description]; })); } function validFile(file) { return file && typeof file.name === "string" && FILE_NAME.test(file.name) && file.name.split("/").every((part) => part !== "." && part !== ".." && part.length > 0) && typeof file.content === "string" && typeof file.mediaType === "string"; } function downloadBlob(blob, name) { const url = URL.createObjectURL(blob); const link = node("a"); link.href = url; link.download = name; document.body.append(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 60_000); } function downloadFile(file) { if (!state.plan || !state.plan.files.includes(file)) return; downloadBlob(new Blob([file.content], { type: file.mediaType }), file.name.replaceAll("/", "__")); } function downloadBundle() { if (!state.plan) return; try { state.archive ??= createHostingArchive(state.plan.files); downloadBlob(new Blob([state.archive], { type: "application/zip" }), `${state.plan.resourceName}.zip`); } catch { error("The complete bundle could not be downloaded safely. Generate a new review."); } } function renderPlan(plan, request) { if (plan?.providerId !== request.providerId || plan.component !== request.component || plan.kind !== "manual" || plan.verification !== "not-live-tested" || plan.resourceName !== `relmio-${request.component}-${request.deploymentId}` || !Array.isArray(plan.files) || plan.files.length === 0 || !plan.files.every(validFile) || !plan.files.some((file) => file.name === "INSTRUCTIONS.md") || !Array.isArray(plan.steps) || !Array.isArray(plan.requirements) || !Array.isArray(plan.limitations) || !Array.isArray(plan.settings) || !plan.connection || typeof plan.connection !== "object") { throw new Error("The returned review is incomplete."); } state.plan = plan; el("hosting-review-details").open = false; el("hosting-reviewed-on").textContent = `Catalog reviewed ${plan.reviewedOn}. Live deployment: NOT-RUN.`; const provider = activeProvider(); appendFacts(el("hosting-review-facts"), [ ["Provider", provider.tested ? provider.label : [provider.label, " ", experimentalBadge()]], ["Resource name", plan.resourceName], ["Verification", "Not live tested"], ["Files", `${plan.files.length} individually downloadable files`], ]); appendList("hosting-requirements", plan.requirements); appendList("hosting-limitations", plan.limitations); appendList("hosting-steps", plan.steps); appendFacts(el("hosting-settings"), plan.settings.map((setting) => [setting.name, String(setting.value)])); el("hosting-connection").textContent = JSON.stringify(plan.connection, null, 2); renderSources(el("hosting-plan-sources"), plan.sources ?? []); const cards = plan.files.map((file) => { const card = node("article", undefined, "hosting-file"); const header = node("div", undefined, "hosting-file__header"); const button = node("button", `Download ${file.name}`, "rm-button rm-button--sm"); button.type = "button"; button.addEventListener("click", () => downloadFile(file)); header.append(node("h4", file.name, "hosting-file__name"), button); card.append(header, node("pre", file.content, "hosting-code")); return card; }); el("hosting-files").replaceChildren(...cards); showView("review"); } el("hosting-provider").addEventListener("change", renderProvider); el("hosting-component").addEventListener("change", renderComponent); el("hosting-model").addEventListener("change", invalidate); el("hosting-inputs").addEventListener("input", invalidate); el("hosting-inputs").addEventListener("change", invalidate); el("hosting-continue").addEventListener("click", () => { if (activeProfile()) showView("details"); }); el("hosting-details-back").addEventListener("click", () => { invalidate(); showView("choose"); }); el("hosting-review-back").addEventListener("click", () => showView("details")); el("hosting-download-bundle").addEventListener("click", downloadBundle); el("hosting-form").addEventListener("submit", async (event) => { event.preventDefault(); const profile = activeProfile(); if (!token || !profile || state.requestPending || !el("hosting-form").reportValidity()) return; invalidate(); const generation = state.generation; const request = { providerId: profile.providerId, component: profile.component, deploymentId: randomDeploymentId(), ...(profile.component === "model" ? { modelId: el("hosting-model").value } : {}), inputs: planInputs(profile), }; state.requestPending = true; el("hosting-generate").disabled = true; el("hosting-status").textContent = "Preparing your plan locally. No server or provider is contacted…"; try { const response = await fetch("/api/hosting/plan", { method: "POST", headers: { "Content-Type": "application/json", "X-Setup-Token": token }, body: JSON.stringify(request), }); const plan = await response.json().catch(() => null); if (!response.ok) { const rejected = "The provider plan was rejected. Check the selected fields and try again."; const failure = wizardApiError(plan, response.status, rejected); // This route answers 400 only for rejected fields; say what to do about it. if (response.status === 400) failure.message = rejected; throw failure; } if (generation !== state.generation) return; renderPlan(plan, request); el("hosting-status").textContent = "Plan ready. No server or provider was contacted."; } catch (failure) { if (generation === state.generation) error(failure); } finally { if (generation === state.generation) { state.requestPending = false; el("hosting-generate").disabled = !activeProfile(); } } }); async function loadCatalog() { if (!token) { el("hosting-status").textContent = "Open this page from the current Relmio session."; return; } try { const response = await fetch("/api/hosting/providers", { headers: { "X-Setup-Token": token } }); const catalog = await response.json().catch(() => null); if (!response.ok) throw wizardApiError(catalog, response.status); if (!Array.isArray(catalog?.providers) || !Array.isArray(catalog.profiles) || !Array.isArray(catalog.models)) { throw new Error("The hosting catalog is unavailable."); } state.providers = catalog.providers; state.profiles = catalog.profiles; state.models = catalog.models; el("hosting-provider").replaceChildren(option("", "Choose hosting"), ...Array.from(providersByKind(), ([kind, providers]) => { const group = node("optgroup"); group.label = KIND_LABELS[kind] ?? kind; group.append(...providers.map((provider) => option(provider.id, provider.label))); return group; })); el("hosting-provider").disabled = false; // Hostinger KVM VPS is the tested VPS route, so it starts selected; any other choice stays one click away. if (state.providers.some((provider) => provider.id === "hostinger")) el("hosting-provider").value = "hostinger"; renderProvider(); el("hosting-status").textContent = activeProvider() ? "Choose what to set up." : "Choose hosting and what to set up."; } catch (failure) { el("hosting-status").textContent = "Catalog unavailable; no plan can be generated."; // One plain sentence on screen; the guide still gets the status and code. failure.message = "The authenticated hosting catalog could not be loaded. Reopen this page from Relmio."; error(failure); } } loadCatalog(); initWizardTopbar({ session: token, isBusy: () => state.requestPending, loadProjectMeta: token ? async () => { const response = await fetch("/api/local/project-meta", { headers: { "X-Setup-Token": token } }); if (!response.ok) throw new Error("Project details are unavailable."); return response.json(); } : undefined, });