#!/usr/bin/env node // CI checks for the one-click local n8n stack. // node scripts/local-stack-ci.mjs compose-config // Renders the stack files for private, Code Sandbox + SearXNG and ngrok setups and runs this // machine's `docker compose config` on them. Needs only the Docker CLI with Compose, so it also // runs on Windows runners, which cannot start Linux containers. // node scripts/local-stack-ci.mjs live // Linux only: installs the private stack with Code Sandbox through the real installer, checks // n8n's health, adds an add-on credential to n8n, then removes the stack. import { execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createAssistantSecrets } from "../src/domain/assistant-templates.js"; import { createLocalN8nStackInstallation, createLocalN8nStackPlan, LOCAL_N8N_STACK_REMOVE_CONFIRMATION, } from "../src/domain/local-n8n-stack.js"; import { getLocalN8nStackStatus, installLocalN8nStack, removeLocalN8nStack, } from "../src/services/local-n8n-stack-installer.js"; import { importLocalStackN8nCredential, localStackCredentialId } from "../src/services/local-n8n-stack-credentials.js"; import { createLocalN8nStackComposeFile, createLocalN8nStackEnv, createNgrokConfig, createNgrokTrafficPolicy, createSearxngSettings, } from "../src/templates/local-n8n-stack/index.js"; const DOCKER_HOST = process.platform === "win32" ? "npipe:////./pipe/dockerDesktopLinuxEngine" : "unix:///var/run/docker.sock"; function docker(args, options = {}) { return execFileSync("docker", args, { encoding: "utf8", maxBuffer: 1 << 26, ...options }); } async function composeConfig() { // `$` and `"` exercise the .env escaping that Compose must read back exactly. const ngrokAuthtoken = "ci$token_with$dollars\"and-quote"; const cases = [ { publicAccess: "none", assistantMode: "disabled" }, { publicAccess: "none", assistantMode: "sandbox-with-searxng" }, { publicAccess: "ngrok", assistantMode: "sandbox" }, ]; for (const setup of cases) { const ngrok = setup.publicAccess === "ngrok"; const plan = createLocalN8nStackPlan({ dockerHost: DOCKER_HOST, publicAccess: setup.publicAccess, ngrokHostname: ngrok ? "relmio-ci.ngrok-free.app" : null, n8nPort: 5679, timezone: "UTC", assistantMode: setup.assistantMode, }); const installation = createLocalN8nStackInstallation({ plan, randomBytes }); const secrets = ngrok ? { ngrokAuthtoken, basicAuthUsername: "relmio-ci", basicAuthPassword: "long-ci-password-$" } : undefined; const runtimeSecrets = { n8nEncryptionKey: randomBytes(32).toString("hex"), ...(setup.assistantMode === "disabled" ? {} : createAssistantSecrets({ randomBytes, includeSearxng: setup.assistantMode === "sandbox-with-searxng", })), }; const directory = await mkdtemp(join(tmpdir(), "relmio stack ci ")); try { await mkdir(join(directory, ".runtime")); await writeFile(join(directory, ".env"), createLocalN8nStackEnv({ installation, secrets, runtimeSecrets })); await writeFile(join(directory, "docker-compose.yml"), createLocalN8nStackComposeFile({ installation })); if (ngrok) { await writeFile(join(directory, "ngrok.yml"), createNgrokConfig()); await writeFile(join(directory, ".runtime", "traffic-policy.yml"), createNgrokTrafficPolicy({ username: secrets.basicAuthUsername, password: secrets.basicAuthPassword })); } if (setup.assistantMode === "sandbox-with-searxng") { await writeFile(join(directory, ".runtime", "searxng-settings.yml"), createSearxngSettings()); } const config = JSON.parse(docker([ "compose", "--project-name", installation.projectName, "--env-file", ".env", "--file", "docker-compose.yml", "config", "--format", "json", ], { cwd: directory })); const services = Object.keys(config.services).sort(); if (Boolean(config.services.ngrok) !== ngrok) throw new Error(`ngrok service presence is wrong for ${setup.publicAccess}`); // `compose config` prints every `$` doubled so its output stays valid Compose; undo that before comparing. if (ngrok && config.services.ngrok.environment.NGROK_AUTHTOKEN.replaceAll("$$", "$") !== ngrokAuthtoken) { throw new Error("Compose did not read the escaped ngrok token back exactly."); } if (ngrok && config.services.ngrok.ports?.length) throw new Error("The ngrok agent API must not be published."); const n8nEnv = config.services.n8n.environment; if (!ngrok && (n8nEnv.N8N_HOST !== "localhost" || n8nEnv.N8N_SECURE_COOKIE !== "false")) { throw new Error("The private stack does not use the localhost n8n settings."); } const certs = config.services["relmio-sandbox-certs"]; if (setup.assistantMode !== "disabled" && (certs?.entrypoint?.[0] !== "sh" || certs?.entrypoint?.[1] !== "-c")) { throw new Error("The Code Sandbox certificate job must override the image entrypoint."); } console.log(`compose config ok: ${setup.publicAccess} + ${setup.assistantMode} -> ${services.join(", ")}`); } finally { await rm(directory, { recursive: true, force: true }); } } } async function waitForHealthz(url, timeoutMs) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { if ((await fetch(url)).ok) return; } catch { /* n8n is still starting */ } await new Promise((resolve) => setTimeout(resolve, 2000)); } throw new Error(`${url} did not become healthy.`); } async function readMarker(homeDirectory) { try { return JSON.parse(await readFile(join(homeDirectory, ".relmio", "local", "n8n-stack", ".managed-by-relmio.json"), "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; } } async function live() { if (process.platform !== "linux") throw new Error("The live stack check runs on Linux CI only."); const homeDirectory = await mkdtemp(join(tmpdir(), "relmio-stack-home-")); const plan = createLocalN8nStackPlan({ dockerHost: DOCKER_HOST, publicAccess: "none", ngrokHostname: null, n8nPort: 5679, timezone: "UTC", assistantMode: "sandbox", }); let liveError = null; try { const result = await installLocalN8nStack({ plan, secrets: undefined, homeDirectory }); console.log(`installed: ${result.localUrl}`); await waitForHealthz("http://127.0.0.1:5679/healthz", 60_000); const status = await getLocalN8nStackStatus({ homeDirectory }); if (status.state !== "healthy") throw new Error(`stack status is ${status.state}`); const marker = await readMarker(homeDirectory); const credential = await importLocalStackN8nCredential({ marker, credential: { feature: "local-model", apiKey: "local-only" }, dockerHost: DOCKER_HOST, homeDirectory, }); if (credential.state !== "created") throw new Error(`credential import ${credential.state}`); const again = await importLocalStackN8nCredential({ marker, credential: { feature: "local-model", apiKey: "local-only" }, dockerHost: DOCKER_HOST, homeDirectory, }); if (again.state !== "updated") throw new Error(`second import ${again.state}`); const id = localStackCredentialId(marker.installId, "local-model"); const exported = docker(["exec", `${marker.projectName}-n8n-1`, "n8n", "export:credentials", `--id=${id}`]); if (!exported.includes(id) || !exported.includes("Relmio local model")) throw new Error("n8n does not list the imported credential."); console.log(`credential in n8n: ${id} Relmio local model`); } catch (error) { liveError = error; } // A marker left on disk means Docker resources may exist, even after a failed install. let cleanupError = null; try { const marker = await readMarker(homeDirectory); if (marker) { await removeLocalN8nStack({ confirmation: LOCAL_N8N_STACK_REMOVE_CONFIRMATION, homeDirectory }); const left = docker(["ps", "--all", "--quiet", "--filter", "label=io.relmio.target=local-n8n-stack", "--filter", `label=com.docker.compose.project=${marker.projectName}`]).trim(); if (left) throw new Error(`Stack ${marker.projectName} containers remained after removal.`); console.log("stack removed"); } await rm(homeDirectory, { recursive: true, force: true }); } catch (error) { cleanupError = error; console.error(`Kept ${homeDirectory} for inspection.`); } const errors = [liveError, cleanupError].filter(Boolean); if (errors.length) throw new AggregateError(errors, errors.map((error) => error.message).join(" | ")); } const mode = process.argv[2]; if (mode === "compose-config") await composeConfig(); else if (mode === "live") await live(); else throw new Error("Usage: node scripts/local-stack-ci.mjs compose-config|live");