|
|
import childProcess from "node:child_process"; |
|
|
import crypto from "node:crypto"; |
|
|
import fs from "node:fs"; |
|
|
import os from "node:os"; |
|
|
import path from "node:path"; |
|
|
|
|
|
import express from "express"; |
|
|
import httpProxy from "http-proxy"; |
|
|
import * as tar from "tar"; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const PORT = Number.parseInt( |
|
|
process.env.MOLTBOT_PUBLIC_PORT ?? |
|
|
process.env.CLAWDBOT_PUBLIC_PORT ?? |
|
|
process.env.PORT ?? "8080", 10 |
|
|
); |
|
|
const STATE_DIR = |
|
|
process.env.MOLTBOT_STATE_DIR?.trim() || |
|
|
process.env.CLAWDBOT_STATE_DIR?.trim() || |
|
|
path.join(os.homedir(), ".moltbot"); |
|
|
const WORKSPACE_DIR = |
|
|
process.env.MOLTBOT_WORKSPACE_DIR?.trim() || |
|
|
process.env.CLAWDBOT_WORKSPACE_DIR?.trim() || |
|
|
path.join(STATE_DIR, "workspace"); |
|
|
|
|
|
|
|
|
const SETUP_PASSWORD = process.env.SETUP_PASSWORD?.trim(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function resolveGatewayToken() { |
|
|
const envTok = |
|
|
process.env.MOLTBOT_GATEWAY_TOKEN?.trim() || |
|
|
process.env.CLAWDBOT_GATEWAY_TOKEN?.trim(); |
|
|
if (envTok) return envTok; |
|
|
|
|
|
const tokenPath = path.join(STATE_DIR, "gateway.token"); |
|
|
try { |
|
|
const existing = fs.readFileSync(tokenPath, "utf8").trim(); |
|
|
if (existing) return existing; |
|
|
} catch { |
|
|
|
|
|
} |
|
|
|
|
|
const generated = crypto.randomBytes(32).toString("hex"); |
|
|
try { |
|
|
fs.mkdirSync(STATE_DIR, { recursive: true }); |
|
|
fs.writeFileSync(tokenPath, generated, { encoding: "utf8", mode: 0o600 }); |
|
|
} catch { |
|
|
|
|
|
} |
|
|
return generated; |
|
|
} |
|
|
|
|
|
const MOLTBOT_GATEWAY_TOKEN = resolveGatewayToken(); |
|
|
process.env.MOLTBOT_GATEWAY_TOKEN = MOLTBOT_GATEWAY_TOKEN; |
|
|
|
|
|
process.env.CLAWDBOT_GATEWAY_TOKEN = MOLTBOT_GATEWAY_TOKEN; |
|
|
|
|
|
|
|
|
const INTERNAL_GATEWAY_PORT = Number.parseInt(process.env.INTERNAL_GATEWAY_PORT ?? "18789", 10); |
|
|
const INTERNAL_GATEWAY_HOST = process.env.INTERNAL_GATEWAY_HOST ?? "127.0.0.1"; |
|
|
const GATEWAY_TARGET = `http://${INTERNAL_GATEWAY_HOST}:${INTERNAL_GATEWAY_PORT}`; |
|
|
|
|
|
|
|
|
|
|
|
const MOLTBOT_ENTRY = process.env.MOLTBOT_ENTRY?.trim() || process.env.CLAWDBOT_ENTRY?.trim() || "/moltbot/dist/entry.js"; |
|
|
const MOLTBOT_NODE = process.env.MOLTBOT_NODE?.trim() || process.env.CLAWDBOT_NODE?.trim() || "node"; |
|
|
|
|
|
function moltArgs(args) { |
|
|
return [MOLTBOT_ENTRY, ...args]; |
|
|
} |
|
|
|
|
|
function configPath() { |
|
|
return process.env.MOLTBOT_CONFIG_PATH?.trim() || |
|
|
process.env.CLAWDBOT_CONFIG_PATH?.trim() || |
|
|
path.join(STATE_DIR, "moltbot.json"); |
|
|
} |
|
|
|
|
|
function isConfigured() { |
|
|
try { |
|
|
return fs.existsSync(configPath()); |
|
|
} catch { |
|
|
return false; |
|
|
} |
|
|
} |
|
|
|
|
|
let gatewayProc = null; |
|
|
let gatewayStarting = null; |
|
|
|
|
|
function sleep(ms) { |
|
|
return new Promise((r) => setTimeout(r, ms)); |
|
|
} |
|
|
|
|
|
async function waitForGatewayReady(opts = {}) { |
|
|
const timeoutMs = opts.timeoutMs ?? 20_000; |
|
|
const start = Date.now(); |
|
|
while (Date.now() - start < timeoutMs) { |
|
|
try { |
|
|
const res = await fetch(`${GATEWAY_TARGET}/moltbot`, { method: "GET" }); |
|
|
|
|
|
if (res) return true; |
|
|
} catch { |
|
|
|
|
|
} |
|
|
await sleep(250); |
|
|
} |
|
|
return false; |
|
|
} |
|
|
|
|
|
async function startGateway() { |
|
|
if (gatewayProc) return; |
|
|
if (!isConfigured()) throw new Error("Gateway cannot start: not configured"); |
|
|
|
|
|
fs.mkdirSync(STATE_DIR, { recursive: true }); |
|
|
fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); |
|
|
|
|
|
const args = [ |
|
|
"gateway", |
|
|
"run", |
|
|
"--bind", |
|
|
"loopback", |
|
|
"--port", |
|
|
String(INTERNAL_GATEWAY_PORT), |
|
|
"--auth", |
|
|
"token", |
|
|
"--token", |
|
|
MOLTBOT_GATEWAY_TOKEN, |
|
|
]; |
|
|
|
|
|
gatewayProc = childProcess.spawn(MOLTBOT_NODE, moltArgs(args), { |
|
|
stdio: "inherit", |
|
|
env: { |
|
|
...process.env, |
|
|
MOLTBOT_STATE_DIR: STATE_DIR, |
|
|
MOLTBOT_WORKSPACE_DIR: WORKSPACE_DIR, |
|
|
|
|
|
CLAWDBOT_STATE_DIR: STATE_DIR, |
|
|
CLAWDBOT_WORKSPACE_DIR: WORKSPACE_DIR, |
|
|
}, |
|
|
}); |
|
|
|
|
|
gatewayProc.on("error", (err) => { |
|
|
console.error(`[gateway] spawn error: ${String(err)}`); |
|
|
gatewayProc = null; |
|
|
}); |
|
|
|
|
|
gatewayProc.on("exit", (code, signal) => { |
|
|
console.error(`[gateway] exited code=${code} signal=${signal}`); |
|
|
gatewayProc = null; |
|
|
}); |
|
|
} |
|
|
|
|
|
async function ensureGatewayRunning() { |
|
|
if (!isConfigured()) return { ok: false, reason: "not configured" }; |
|
|
if (gatewayProc) return { ok: true }; |
|
|
if (!gatewayStarting) { |
|
|
gatewayStarting = (async () => { |
|
|
await startGateway(); |
|
|
const ready = await waitForGatewayReady({ timeoutMs: 20_000 }); |
|
|
if (!ready) { |
|
|
throw new Error("Gateway did not become ready in time"); |
|
|
} |
|
|
})().finally(() => { |
|
|
gatewayStarting = null; |
|
|
}); |
|
|
} |
|
|
await gatewayStarting; |
|
|
return { ok: true }; |
|
|
} |
|
|
|
|
|
async function restartGateway() { |
|
|
if (gatewayProc) { |
|
|
try { |
|
|
gatewayProc.kill("SIGTERM"); |
|
|
} catch { |
|
|
|
|
|
} |
|
|
|
|
|
await sleep(750); |
|
|
gatewayProc = null; |
|
|
} |
|
|
return ensureGatewayRunning(); |
|
|
} |
|
|
|
|
|
function requireSetupAuth(req, res, next) { |
|
|
if (!SETUP_PASSWORD) { |
|
|
return res |
|
|
.status(500) |
|
|
.type("text/plain") |
|
|
.send("SETUP_PASSWORD is not set. Set it in Railway Variables before using /setup."); |
|
|
} |
|
|
|
|
|
const header = req.headers.authorization || ""; |
|
|
const [scheme, encoded] = header.split(" "); |
|
|
if (scheme !== "Basic" || !encoded) { |
|
|
res.set("WWW-Authenticate", 'Basic realm="Moltbot Setup"'); |
|
|
return res.status(401).send("Auth required"); |
|
|
} |
|
|
const decoded = Buffer.from(encoded, "base64").toString("utf8"); |
|
|
const idx = decoded.indexOf(":"); |
|
|
const password = idx >= 0 ? decoded.slice(idx + 1) : ""; |
|
|
if (password !== SETUP_PASSWORD) { |
|
|
res.set("WWW-Authenticate", 'Basic realm="Moltbot Setup"'); |
|
|
return res.status(401).send("Invalid password"); |
|
|
} |
|
|
return next(); |
|
|
} |
|
|
|
|
|
const app = express(); |
|
|
app.disable("x-powered-by"); |
|
|
app.use(express.json({ limit: "1mb" })); |
|
|
|
|
|
|
|
|
app.get("/setup/healthz", (_req, res) => res.json({ ok: true })); |
|
|
|
|
|
|
|
|
const PROVIDER_TEMPLATES = { |
|
|
openai: { |
|
|
name: "OpenAI", |
|
|
description: "GPT-4 and GPT-3.5 models", |
|
|
authChoice: "openai-api-key", |
|
|
placeholder: "sk-...", |
|
|
fields: { |
|
|
authSecret: { |
|
|
label: "API Key", |
|
|
type: "password", |
|
|
help: "Get your key from https://platform.openai.com/api-keys", |
|
|
helpUrl: "https://platform.openai.com/api-keys" |
|
|
} |
|
|
}, |
|
|
icon: "🤖" |
|
|
}, |
|
|
anthropic: { |
|
|
name: "Anthropic Claude", |
|
|
description: "Claude 3.5 Sonnet, Opus, and Haiku", |
|
|
authChoice: "anthropic-api-key", |
|
|
placeholder: "sk-ant-...", |
|
|
fields: { |
|
|
authSecret: { |
|
|
label: "API Key", |
|
|
type: "password", |
|
|
help: "Get your key from https://console.anthropic.com/", |
|
|
helpUrl: "https://console.anthropic.com/" |
|
|
} |
|
|
}, |
|
|
icon: "🧠" |
|
|
}, |
|
|
google: { |
|
|
name: "Google Gemini", |
|
|
description: "Gemini Pro and Ultra models", |
|
|
authChoice: "gemini-api-key", |
|
|
placeholder: "AIza...", |
|
|
fields: { |
|
|
authSecret: { |
|
|
label: "API Key", |
|
|
type: "password", |
|
|
help: "Get your key from https://makersuite.google.com/app/apikey", |
|
|
helpUrl: "https://makersuite.google.com/app/apikey" |
|
|
} |
|
|
}, |
|
|
icon: "💎" |
|
|
}, |
|
|
atlascloud: { |
|
|
name: "Atlas Cloud", |
|
|
description: "Multi-provider API with OpenAI, Anthropic, and more", |
|
|
authChoice: "atlascloud-api-key", |
|
|
placeholder: "aat_...", |
|
|
fields: { |
|
|
authSecret: { |
|
|
label: "API Key", |
|
|
type: "password", |
|
|
help: "Get your key from your Atlas Cloud dashboard", |
|
|
helpUrl: "https://atlascloud.ai" |
|
|
}, |
|
|
baseUrl: { |
|
|
label: "Base URL (optional)", |
|
|
type: "text", |
|
|
placeholder: "https://api.atlascloud.ai/v1", |
|
|
default: "https://api.atlascloud.ai/v1" |
|
|
} |
|
|
}, |
|
|
icon: "☁️" |
|
|
}, |
|
|
openrouter: { |
|
|
name: "OpenRouter", |
|
|
description: "Access to 100+ AI models through one API", |
|
|
authChoice: "openrouter-api-key", |
|
|
placeholder: "sk-or-...", |
|
|
fields: { |
|
|
authSecret: { |
|
|
label: "API Key", |
|
|
type: "password", |
|
|
help: "Get your key from https://openrouter.ai/keys", |
|
|
helpUrl: "https://openrouter.ai/keys" |
|
|
} |
|
|
}, |
|
|
icon: "🔀" |
|
|
} |
|
|
}; |
|
|
|
|
|
app.get("/setup/api/templates", requireSetupAuth, (_req, res) => { |
|
|
res.json({ templates: PROVIDER_TEMPLATES }); |
|
|
}); |
|
|
|
|
|
app.get("/setup/api/templates/:provider", requireSetupAuth, (req, res) => { |
|
|
const template = PROVIDER_TEMPLATES[req.params.provider]; |
|
|
if (!template) { |
|
|
return res.status(404).json({ error: "Template not found" }); |
|
|
} |
|
|
res.json(template); |
|
|
}); |
|
|
|
|
|
|
|
|
app.get("/setup/api/check", requireSetupAuth, async (_req, res) => { |
|
|
const checks = []; |
|
|
|
|
|
|
|
|
try { |
|
|
const versionResult = await runCmd(MOLTBOT_NODE, moltArgs(["--version"])); |
|
|
checks.push({ |
|
|
name: "Moltbot CLI", |
|
|
status: "ok", |
|
|
message: `Version ${versionResult.output.trim()}` |
|
|
}); |
|
|
} catch (err) { |
|
|
checks.push({ |
|
|
name: "Moltbot CLI", |
|
|
status: "error", |
|
|
message: `CLI not accessible: ${String(err)}` |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
try { |
|
|
fs.mkdirSync(STATE_DIR, { recursive: true }); |
|
|
const stateAccessible = fs.accessSync(STATE_DIR, fs.constants.W_OK); |
|
|
checks.push({ |
|
|
name: "State Directory", |
|
|
status: "ok", |
|
|
message: `Writable: ${STATE_DIR}` |
|
|
}); |
|
|
} catch (err) { |
|
|
checks.push({ |
|
|
name: "State Directory", |
|
|
status: "error", |
|
|
message: `Cannot write to ${STATE_DIR}: ${String(err)}` |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
try { |
|
|
fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); |
|
|
checks.push({ |
|
|
name: "Workspace Directory", |
|
|
status: "ok", |
|
|
message: `Writable: ${WORKSPACE_DIR}` |
|
|
}); |
|
|
} catch (err) { |
|
|
checks.push({ |
|
|
name: "Workspace Directory", |
|
|
status: "error", |
|
|
message: `Cannot write to ${WORKSPACE_DIR}: ${String(err)}` |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
const totalMem = Math.round(os.totalmem() / 1024 / 1024); |
|
|
const freeMem = Math.round(os.freemem() / 1024 / 1024); |
|
|
checks.push({ |
|
|
name: "Available Memory", |
|
|
status: freeMem > 256 ? "ok" : "warning", |
|
|
message: `${freeMem}MB available (512MB+ recommended)`, |
|
|
value: freeMem |
|
|
}); |
|
|
|
|
|
|
|
|
try { |
|
|
const stats = await fs.promises.statfs(STATE_DIR); |
|
|
|
|
|
if (stats && typeof stats.bavail === 'number' && typeof stats.frsize === 'number' && stats.bavail > 0 && stats.frsize > 0) { |
|
|
const freeSpace = Math.round(stats.bavail * stats.frsize / 1024 / 1024); |
|
|
checks.push({ |
|
|
name: "Disk Space", |
|
|
status: freeSpace > 100 ? "ok" : "warning", |
|
|
message: `${freeSpace}MB available`, |
|
|
value: freeSpace |
|
|
}); |
|
|
} else { |
|
|
|
|
|
|
|
|
checks.push({ |
|
|
name: "Disk Space", |
|
|
status: "ok", |
|
|
message: "OK (could not measure, Railway volumes typically 1GB+)" |
|
|
}); |
|
|
} |
|
|
} catch (err) { |
|
|
|
|
|
|
|
|
checks.push({ |
|
|
name: "Disk Space", |
|
|
status: "ok", |
|
|
message: "OK (could not measure, Railway volumes typically 1GB+)" |
|
|
}); |
|
|
} |
|
|
|
|
|
const allOk = checks.every(c => c.status === "ok"); |
|
|
res.json({ |
|
|
ready: allOk, |
|
|
checks, |
|
|
summary: allOk ? "All checks passed. Ready to setup." : "Some checks failed. Please fix issues before proceeding." |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
app.post("/setup/api/validate-token", requireSetupAuth, async (req, res) => { |
|
|
const { provider, token, baseUrl } = req.body || {}; |
|
|
|
|
|
if (!provider || !token) { |
|
|
return res.status(400).json({ valid: false, error: "Provider and token are required" }); |
|
|
} |
|
|
|
|
|
|
|
|
let formatValid = true; |
|
|
let formatError = ""; |
|
|
|
|
|
switch (provider) { |
|
|
case "openai-api-key": |
|
|
if (!token.startsWith("sk-")) { |
|
|
formatValid = false; |
|
|
formatError = "OpenAI API keys should start with 'sk-'"; |
|
|
} |
|
|
break; |
|
|
case "anthropic-api-key": |
|
|
if (!token.startsWith("sk-ant-")) { |
|
|
formatValid = false; |
|
|
formatError = "Anthropic API keys should start with 'sk-ant-'"; |
|
|
} |
|
|
break; |
|
|
case "gemini-api-key": |
|
|
if (!token.startsWith("AIza")) { |
|
|
formatValid = false; |
|
|
formatError = "Gemini API keys should start with 'AIza'"; |
|
|
} |
|
|
break; |
|
|
case "atlascloud-api-key": |
|
|
if (!token.startsWith("aat_")) { |
|
|
formatValid = false; |
|
|
formatError = "Atlas Cloud API keys should start with 'aat_'"; |
|
|
} |
|
|
break; |
|
|
case "openrouter-api-key": |
|
|
if (!token.startsWith("sk-or-")) { |
|
|
formatValid = false; |
|
|
formatError = "OpenRouter API keys should start with 'sk-or-'"; |
|
|
} |
|
|
break; |
|
|
} |
|
|
|
|
|
if (!formatValid) { |
|
|
return res.json({ valid: false, error: formatError }); |
|
|
} |
|
|
|
|
|
|
|
|
try { |
|
|
let validationOk = false; |
|
|
let providerName = ""; |
|
|
|
|
|
if (provider === "openai-api-key") { |
|
|
|
|
|
const response = await fetch("https://api.openai.com/v1/models", { |
|
|
headers: { "Authorization": `Bearer ${token}` }, |
|
|
signal: AbortSignal.timeout(5000) |
|
|
}); |
|
|
validationOk = response.ok; |
|
|
providerName = "OpenAI"; |
|
|
} else if (provider === "anthropic-api-key") { |
|
|
const response = await fetch("https://api.anthropic.com/v1/messages", { |
|
|
method: "POST", |
|
|
headers: { |
|
|
"x-api-key": token, |
|
|
"anthropic-version": "2023-06-01", |
|
|
"content-type": "application/json" |
|
|
}, |
|
|
body: JSON.stringify({ |
|
|
model: "claude-3-haiku-20240307", |
|
|
max_tokens: 1, |
|
|
messages: [{ role: "user", content: "test" }] |
|
|
}), |
|
|
signal: AbortSignal.timeout(5000) |
|
|
}); |
|
|
validationOk = response.status !== 401; |
|
|
providerName = "Anthropic"; |
|
|
} else if (provider === "atlascloud-api-key") { |
|
|
const apiUrl = baseUrl || "https://api.atlascloud.ai/v1"; |
|
|
const response = await fetch(`${apiUrl}/models`, { |
|
|
headers: { "Authorization": `Bearer ${token}` }, |
|
|
signal: AbortSignal.timeout(5000) |
|
|
}); |
|
|
validationOk = response.ok; |
|
|
providerName = "Atlas Cloud"; |
|
|
} else { |
|
|
|
|
|
validationOk = true; |
|
|
} |
|
|
|
|
|
res.json({ |
|
|
valid: validationOk, |
|
|
provider: providerName, |
|
|
message: validationOk ? "Token validated successfully" : "Token validation failed" |
|
|
}); |
|
|
} catch (err) { |
|
|
res.json({ |
|
|
valid: false, |
|
|
error: `Validation failed: ${err.message}` |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
app.get("/setup/api/progress", requireSetupAuth, async (req, res) => { |
|
|
res.setHeader("Content-Type", "text/event-stream"); |
|
|
res.setHeader("Cache-Control", "no-cache"); |
|
|
res.setHeader("Connection", "keep-alive"); |
|
|
|
|
|
const sendEvent = (event, data) => { |
|
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); |
|
|
}; |
|
|
|
|
|
try { |
|
|
|
|
|
if (isConfigured()) { |
|
|
sendEvent("progress", { stage: "complete", message: "Already configured" }); |
|
|
await ensureGatewayRunning(); |
|
|
sendEvent("done", { success: true, message: "Setup already complete" }); |
|
|
return res.end(); |
|
|
} |
|
|
|
|
|
sendEvent("progress", { stage: "starting", message: "Initializing setup..." }); |
|
|
|
|
|
|
|
|
fs.mkdirSync(STATE_DIR, { recursive: true }); |
|
|
fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); |
|
|
sendEvent("progress", { stage: "directories", message: "Created state directories" }); |
|
|
|
|
|
|
|
|
|
|
|
sendEvent("progress", { stage: "waiting", message: "Waiting for setup parameters..." }); |
|
|
|
|
|
} catch (err) { |
|
|
sendEvent("error", { error: String(err) }); |
|
|
res.end(); |
|
|
} |
|
|
}); |
|
|
|
|
|
app.get("/setup/app.js", requireSetupAuth, (_req, res) => { |
|
|
|
|
|
res.type("application/javascript"); |
|
|
res.send(fs.readFileSync(path.join(process.cwd(), "src", "setup-app.js"), "utf8")); |
|
|
}); |
|
|
|
|
|
app.get("/setup", requireSetupAuth, (_req, res) => { |
|
|
|
|
|
res.type("html").send(`<!doctype html> |
|
|
<html> |
|
|
<head> |
|
|
<meta charset="utf-8" /> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
|
|
<title>Moltbot Setup</title> |
|
|
<style> |
|
|
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; margin: 2rem; max-width: 900px; } |
|
|
.card { border: 1px solid #ddd; border-radius: 12px; padding: 1.25rem; margin: 1rem 0; } |
|
|
label { display:block; margin-top: 0.75rem; font-weight: 600; } |
|
|
input, select { width: 100%; padding: 0.6rem; margin-top: 0.25rem; } |
|
|
button { padding: 0.8rem 1.2rem; border-radius: 10px; border: 0; background: #111; color: #fff; font-weight: 700; cursor: pointer; } |
|
|
code { background: #f6f6f6; padding: 0.1rem 0.3rem; border-radius: 6px; } |
|
|
.muted { color: #555; } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<h1>Moltbot Setup</h1> |
|
|
<p class="muted">This wizard configures Moltbot by running the same onboarding command it uses in the terminal, but from the browser.</p> |
|
|
|
|
|
<div class="card"> |
|
|
<h2>Status</h2> |
|
|
<div id="status">Loading...</div> |
|
|
<div style="margin-top: 0.75rem"> |
|
|
<a href="/moltbot" target="_blank">Open Moltbot UI</a> |
|
|
| |
|
|
<a href="/setup/export" target="_blank">Download backup (.tar.gz)</a> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="card"> |
|
|
<h2>🚀 Quick Setup</h2> |
|
|
<p class="muted">Click a provider to auto-fill configuration:</p> |
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.5rem; margin: 1rem 0;"> |
|
|
<button onclick="useProvider('openai')" style="background:#10a37f; padding:0.6rem;">🤖 OpenAI</button> |
|
|
<button onclick="useProvider('anthropic')" style="background:#d4a573; color:white; padding:0.6rem;">🧠 Anthropic</button> |
|
|
<button onclick="useProvider('google')" style="background:#4285f4; color:white; padding:0.6rem;">💎 Gemini</button> |
|
|
<button onclick="useProvider('atlascloud')" style="background:#8b5cf6; color:white; padding:0.6rem;">☁️ Atlas Cloud</button> |
|
|
<button onclick="useProvider('openrouter')" style="background:#6366f1; color:white; padding:0.6rem;">🔀 OpenRouter</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="card"> |
|
|
<h2>✅ Pre-flight Checks</h2> |
|
|
<button onclick="runPreflightChecks()" style="margin-bottom: 0.75rem;">Run Checks</button> |
|
|
<div id="preflightResults" style="margin-top: 0.75rem;"></div> |
|
|
</div> |
|
|
|
|
|
<div class="card"> |
|
|
<h2>1) Model/auth provider</h2> |
|
|
<p class="muted">Matches the groups shown in the terminal onboarding.</p> |
|
|
<label>Provider group</label> |
|
|
<select id="authGroup"></select> |
|
|
|
|
|
<label>Auth method</label> |
|
|
<select id="authChoice"></select> |
|
|
|
|
|
<label>Key / Token (if required)</label> |
|
|
<input id="authSecret" type="password" placeholder="Paste API key / token if applicable" onblur="validateToken()" /> |
|
|
<button id="validateBtn" onclick="validateToken()" type="button" style="margin-top:0.5rem; padding:0.5rem 1rem; background:#6b7280; font-size:0.9rem;">Validate Token</button> |
|
|
<div id="authSecretHelp" class="muted" style="margin-top: 0.5rem; display:none;"></div> |
|
|
|
|
|
<label>Base URL (optional, for Atlas Cloud)</label> |
|
|
<input id="baseUrl" type="text" placeholder="https://api.atlascloud.ai/v1" /> |
|
|
<div class="muted" style="margin-top: 0.25rem;">Leave blank to use provider default</div> |
|
|
|
|
|
<label>Wizard flow</label> |
|
|
<select id="flow"> |
|
|
<option value="quickstart">quickstart</option> |
|
|
<option value="advanced">advanced</option> |
|
|
<option value="manual">manual</option> |
|
|
</select> |
|
|
</div> |
|
|
|
|
|
<div class="card"> |
|
|
<h2>2) Optional: Channels</h2> |
|
|
<p class="muted">You can also add channels later inside Moltbot, but this helps you get messaging working immediately.</p> |
|
|
|
|
|
<label>Telegram bot token (optional)</label> |
|
|
<input id="telegramToken" type="password" placeholder="123456:ABC..." /> |
|
|
<div class="muted" style="margin-top: 0.25rem"> |
|
|
Get it from BotFather: open Telegram, message <code>@BotFather</code>, run <code>/newbot</code>, then copy the token. |
|
|
</div> |
|
|
|
|
|
<label>Discord bot token (optional)</label> |
|
|
<input id="discordToken" type="password" placeholder="Bot token" /> |
|
|
<div class="muted" style="margin-top: 0.25rem"> |
|
|
Get it from the Discord Developer Portal: create an application, add a Bot, then copy the Bot Token.<br/> |
|
|
<strong>Important:</strong> Enable <strong>MESSAGE CONTENT INTENT</strong> in Bot → Privileged Gateway Intents, or the bot will crash on startup. |
|
|
</div> |
|
|
|
|
|
<label>Slack bot token (optional)</label> |
|
|
<input id="slackBotToken" type="password" placeholder="xoxb-..." /> |
|
|
|
|
|
<label>Slack app token (optional)</label> |
|
|
<input id="slackAppToken" type="password" placeholder="xapp-..." /> |
|
|
</div> |
|
|
|
|
|
<div class="card"> |
|
|
<h2>3) Run onboarding</h2> |
|
|
<button id="run">Run setup</button> |
|
|
<button id="pairingApprove" style="background:#1f2937; margin-left:0.5rem">Approve pairing</button> |
|
|
<button id="reset" style="background:#444; margin-left:0.5rem">Reset setup</button> |
|
|
<button id="debug" style="background:#1f2937; margin-left:0.5rem">Debug info</button> |
|
|
<button id="test" style="background:#059669; margin-left:0.5rem">Test endpoint</button> |
|
|
<pre id="log" style="white-space:pre-wrap"></pre> |
|
|
<p class="muted">Reset deletes the Moltbot config file so you can rerun onboarding. Pairing approval lets you grant DM access when dmPolicy=pairing. Debug info shows current system state. Test endpoint verifies routing works.</p> |
|
|
</div> |
|
|
|
|
|
<script src="/setup/app.js"></script> |
|
|
</body> |
|
|
</html>`); |
|
|
}); |
|
|
|
|
|
app.get("/setup/api/status", requireSetupAuth, async (_req, res) => { |
|
|
const version = await runCmd(MOLTBOT_NODE, moltArgs(["--version"])); |
|
|
const channelsHelp = await runCmd(MOLTBOT_NODE, moltArgs(["channels", "add", "--help"])); |
|
|
|
|
|
|
|
|
|
|
|
const authGroups = [ |
|
|
{ value: "openai", label: "OpenAI", hint: "Codex OAuth + API key", options: [ |
|
|
{ value: "codex-cli", label: "OpenAI Codex OAuth (Codex CLI)" }, |
|
|
{ value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" }, |
|
|
{ value: "openai-api-key", label: "OpenAI API key" } |
|
|
]}, |
|
|
{ value: "anthropic", label: "Anthropic", hint: "Claude Code CLI + API key", options: [ |
|
|
{ value: "claude-cli", label: "Anthropic token (Claude Code CLI)" }, |
|
|
{ value: "token", label: "Anthropic token (paste setup-token)" }, |
|
|
{ value: "apiKey", label: "Anthropic API key" } |
|
|
]}, |
|
|
{ value: "google", label: "Google", hint: "Gemini API key + OAuth", options: [ |
|
|
{ value: "gemini-api-key", label: "Google Gemini API key" }, |
|
|
{ value: "google-antigravity", label: "Google Antigravity OAuth" }, |
|
|
{ value: "google-gemini-cli", label: "Google Gemini CLI OAuth" } |
|
|
]}, |
|
|
{ value: "openrouter", label: "OpenRouter", hint: "API key", options: [ |
|
|
{ value: "openrouter-api-key", label: "OpenRouter API key" } |
|
|
]}, |
|
|
{ value: "atlascloud", label: "Atlas Cloud", hint: "Multi-provider API", options: [ |
|
|
{ value: "atlascloud-api-key", label: "Atlas Cloud API key" } |
|
|
]}, |
|
|
{ value: "ai-gateway", label: "Vercel AI Gateway", hint: "API key", options: [ |
|
|
{ value: "ai-gateway-api-key", label: "Vercel AI Gateway API key" } |
|
|
]}, |
|
|
{ value: "moonshot", label: "Moonshot AI", hint: "Kimi K2 + Kimi Code", options: [ |
|
|
{ value: "moonshot-api-key", label: "Moonshot AI API key" }, |
|
|
{ value: "kimi-code-api-key", label: "Kimi Code API key" } |
|
|
]}, |
|
|
{ value: "zai", label: "Z.AI (GLM 4.7)", hint: "API key", options: [ |
|
|
{ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" } |
|
|
]}, |
|
|
{ value: "minimax", label: "MiniMax", hint: "M2.1 (recommended)", options: [ |
|
|
{ value: "minimax-api", label: "MiniMax M2.1" }, |
|
|
{ value: "minimax-api-lightning", label: "MiniMax M2.1 Lightning" } |
|
|
]}, |
|
|
{ value: "qwen", label: "Qwen", hint: "OAuth", options: [ |
|
|
{ value: "qwen-portal", label: "Qwen OAuth" } |
|
|
]}, |
|
|
{ value: "copilot", label: "Copilot", hint: "GitHub + local proxy", options: [ |
|
|
{ value: "github-copilot", label: "GitHub Copilot (GitHub device login)" }, |
|
|
{ value: "copilot-proxy", label: "Copilot Proxy (local)" } |
|
|
]}, |
|
|
{ value: "synthetic", label: "Synthetic", hint: "Anthropic-compatible (multi-model)", options: [ |
|
|
{ value: "synthetic-api-key", label: "Synthetic API key" } |
|
|
]}, |
|
|
{ value: "opencode-zen", label: "OpenCode Zen", hint: "API key", options: [ |
|
|
{ value: "opencode-zen", label: "OpenCode Zen (multi-model proxy)" } |
|
|
]} |
|
|
]; |
|
|
|
|
|
res.json({ |
|
|
configured: isConfigured(), |
|
|
gatewayTarget: GATEWAY_TARGET, |
|
|
moltbotVersion: version.output.trim(), |
|
|
channelsAddHelp: channelsHelp.output, |
|
|
authGroups, |
|
|
}); |
|
|
}); |
|
|
|
|
|
function buildOnboardArgs(payload) { |
|
|
const args = [ |
|
|
"onboard", |
|
|
"--non-interactive", |
|
|
"--accept-risk", |
|
|
"--json", |
|
|
"--no-install-daemon", |
|
|
"--skip-health", |
|
|
"--workspace", |
|
|
WORKSPACE_DIR, |
|
|
|
|
|
"--gateway-bind", |
|
|
"loopback", |
|
|
"--gateway-port", |
|
|
String(INTERNAL_GATEWAY_PORT), |
|
|
"--gateway-auth", |
|
|
"token", |
|
|
"--gateway-token", |
|
|
MOLTBOT_GATEWAY_TOKEN, |
|
|
"--flow", |
|
|
payload.flow || "quickstart" |
|
|
]; |
|
|
|
|
|
if (payload.authChoice) { |
|
|
args.push("--auth-choice", payload.authChoice); |
|
|
|
|
|
|
|
|
const secret = (payload.authSecret || "").trim(); |
|
|
const map = { |
|
|
"openai-api-key": "--openai-api-key", |
|
|
"apiKey": "--anthropic-api-key", |
|
|
"atlascloud-api-key": "--atlascloud-api-key", |
|
|
"openrouter-api-key": "--openrouter-api-key", |
|
|
"ai-gateway-api-key": "--ai-gateway-api-key", |
|
|
"moonshot-api-key": "--moonshot-api-key", |
|
|
"kimi-code-api-key": "--kimi-code-api-key", |
|
|
"gemini-api-key": "--gemini-api-key", |
|
|
"zai-api-key": "--zai-api-key", |
|
|
"minimax-api": "--minimax-api-key", |
|
|
"minimax-api-lightning": "--minimax-api-key", |
|
|
"synthetic-api-key": "--synthetic-api-key", |
|
|
"opencode-zen": "--opencode-zen-api-key" |
|
|
}; |
|
|
const flag = map[payload.authChoice]; |
|
|
if (flag && secret) { |
|
|
args.push(flag, secret); |
|
|
} |
|
|
|
|
|
if (payload.authChoice === "token" && secret) { |
|
|
|
|
|
args.push("--token-provider", "anthropic", "--token", secret); |
|
|
} |
|
|
} |
|
|
|
|
|
return args; |
|
|
} |
|
|
|
|
|
function runCmd(cmd, args, opts = {}) { |
|
|
return new Promise((resolve) => { |
|
|
const proc = childProcess.spawn(cmd, args, { |
|
|
...opts, |
|
|
env: { |
|
|
...process.env, |
|
|
CLAWDBOT_STATE_DIR: STATE_DIR, |
|
|
CLAWDBOT_WORKSPACE_DIR: WORKSPACE_DIR, |
|
|
}, |
|
|
}); |
|
|
|
|
|
let out = ""; |
|
|
proc.stdout?.on("data", (d) => (out += d.toString("utf8"))); |
|
|
proc.stderr?.on("data", (d) => (out += d.toString("utf8"))); |
|
|
|
|
|
proc.on("error", (err) => { |
|
|
out += `\n[spawn error] ${String(err)}\n`; |
|
|
resolve({ code: 127, output: out }); |
|
|
}); |
|
|
|
|
|
proc.on("close", (code) => resolve({ code: code ?? 0, output: out })); |
|
|
}); |
|
|
} |
|
|
|
|
|
app.post("/setup/api/run", requireSetupAuth, async (req, res) => { |
|
|
try { |
|
|
if (isConfigured()) { |
|
|
await ensureGatewayRunning(); |
|
|
return res.json({ ok: true, output: "Already configured.\nUse Reset setup if you want to rerun onboarding.\n" }); |
|
|
} |
|
|
|
|
|
fs.mkdirSync(STATE_DIR, { recursive: true }); |
|
|
fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); |
|
|
|
|
|
const payload = req.body || {}; |
|
|
const onboardArgs = buildOnboardArgs(payload); |
|
|
|
|
|
|
|
|
console.error('[DEBUG] Onboard args:', onboardArgs); |
|
|
console.error('[DEBUG] Payload authChoice:', payload.authChoice); |
|
|
console.error('[DEBUG] Payload authSecret length:', payload.authSecret?.length || 0); |
|
|
console.error('[DEBUG] Config path:', configPath()); |
|
|
|
|
|
const onboard = await runCmd(MOLTBOT_NODE, moltArgs(onboardArgs)); |
|
|
|
|
|
console.error('[DEBUG] Onboard exit code:', onboard.code); |
|
|
console.error('[DEBUG] isConfigured() after onboard:', isConfigured()); |
|
|
console.error('[DEBUG] Config file exists:', fs.existsSync(configPath())); |
|
|
|
|
|
let extra = ""; |
|
|
|
|
|
const ok = onboard.code === 0 && isConfigured(); |
|
|
|
|
|
console.error('[DEBUG] Setup ok status:', ok, '(exit code:', onboard.code, ', configured:', isConfigured(), ')'); |
|
|
|
|
|
|
|
|
if (ok) { |
|
|
console.error('[DEBUG] Configuring gateway settings...'); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
await runCmd(MOLTBOT_NODE, moltArgs(["config", "set", "gateway.auth.mode", "none"])); |
|
|
await runCmd(MOLTBOT_NODE, moltArgs(["config", "set", "gateway.bind", "loopback"])); |
|
|
await runCmd(MOLTBOT_NODE, moltArgs(["config", "set", "gateway.port", String(INTERNAL_GATEWAY_PORT)])); |
|
|
|
|
|
console.error('[DEBUG] Gateway settings configured, restarting gateway...'); |
|
|
|
|
|
const channelsHelp = await runCmd(MOLTBOT_NODE, moltArgs(["channels", "add", "--help"])); |
|
|
const helpText = channelsHelp.output || ""; |
|
|
|
|
|
const supports = (name) => helpText.includes(name); |
|
|
|
|
|
if (payload.telegramToken?.trim()) { |
|
|
if (!supports("telegram")) { |
|
|
extra += "\n[telegram] skipped (this moltbot build does not list telegram in `channels add --help`)\n"; |
|
|
} else { |
|
|
|
|
|
const token = payload.telegramToken.trim(); |
|
|
const cfgObj = { |
|
|
enabled: true, |
|
|
dmPolicy: "pairing", |
|
|
botToken: token, |
|
|
groupPolicy: "allowlist", |
|
|
streamMode: "partial", |
|
|
}; |
|
|
const set = await runCmd( |
|
|
MOLTBOT_NODE, |
|
|
moltArgs(["config", "set", "--json", "channels.telegram", JSON.stringify(cfgObj)]), |
|
|
); |
|
|
const get = await runCmd(MOLTBOT_NODE, moltArgs(["config", "get", "channels.telegram"])); |
|
|
extra += `\n[telegram config] exit=${set.code} (output ${set.output.length} chars)\n${set.output || "(no output)"}`; |
|
|
extra += `\n[telegram verify] exit=${get.code} (output ${get.output.length} chars)\n${get.output || "(no output)"}`; |
|
|
} |
|
|
} |
|
|
|
|
|
if (payload.discordToken?.trim()) { |
|
|
if (!supports("discord")) { |
|
|
extra += "\n[discord] skipped (this moltbot build does not list discord in `channels add --help`)\n"; |
|
|
} else { |
|
|
const token = payload.discordToken.trim(); |
|
|
const cfgObj = { |
|
|
enabled: true, |
|
|
token, |
|
|
groupPolicy: "allowlist", |
|
|
dm: { |
|
|
policy: "pairing", |
|
|
}, |
|
|
}; |
|
|
const set = await runCmd( |
|
|
MOLTBOT_NODE, |
|
|
moltArgs(["config", "set", "--json", "channels.discord", JSON.stringify(cfgObj)]), |
|
|
); |
|
|
const get = await runCmd(MOLTBOT_NODE, moltArgs(["config", "get", "channels.discord"])); |
|
|
extra += `\n[discord config] exit=${set.code} (output ${set.output.length} chars)\n${set.output || "(no output)"}`; |
|
|
extra += `\n[discord verify] exit=${get.code} (output ${get.output.length} chars)\n${get.output || "(no output)"}`; |
|
|
} |
|
|
} |
|
|
|
|
|
if (payload.slackBotToken?.trim() || payload.slackAppToken?.trim()) { |
|
|
if (!supports("slack")) { |
|
|
extra += "\n[slack] skipped (this moltbot build does not list slack in `channels add --help`)\n"; |
|
|
} else { |
|
|
const cfgObj = { |
|
|
enabled: true, |
|
|
botToken: payload.slackBotToken?.trim() || undefined, |
|
|
appToken: payload.slackAppToken?.trim() || undefined, |
|
|
}; |
|
|
const set = await runCmd( |
|
|
MOLTBOT_NODE, |
|
|
moltArgs(["config", "set", "--json", "channels.slack", JSON.stringify(cfgObj)]), |
|
|
); |
|
|
const get = await runCmd(MOLTBOT_NODE, moltArgs(["config", "get", "channels.slack"])); |
|
|
extra += `\n[slack config] exit=${set.code} (output ${set.output.length} chars)\n${set.output || "(no output)"}`; |
|
|
extra += `\n[slack verify] exit=${get.code} (output ${get.output.length} chars)\n${get.output || "(no output)"}`; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
console.error('[DEBUG] Restarting gateway...'); |
|
|
await restartGateway(); |
|
|
console.error('[DEBUG] Gateway restarted, checking if running...'); |
|
|
const running = gatewayProc !== null; |
|
|
console.error('[DEBUG] Gateway running after restart:', running); |
|
|
} |
|
|
|
|
|
console.error('[DEBUG] Returning response: ok=', ok); |
|
|
return res.status(ok ? 200 : 500).json({ |
|
|
ok, |
|
|
output: `${onboard.output}${extra}`, |
|
|
}); |
|
|
} catch (err) { |
|
|
console.error("[/setup/api/run] error:", err); |
|
|
return res.status(500).json({ ok: false, output: `Internal error: ${String(err)}` }); |
|
|
} |
|
|
}); |
|
|
|
|
|
app.get("/setup/api/debug", requireSetupAuth, async (_req, res) => { |
|
|
const v = await runCmd(MOLTBOT_NODE, moltArgs(["--version"])); |
|
|
const help = await runCmd(MOLTBOT_NODE, moltArgs(["channels", "add", "--help"])); |
|
|
res.json({ |
|
|
wrapper: { |
|
|
node: process.version, |
|
|
port: PORT, |
|
|
stateDir: STATE_DIR, |
|
|
workspaceDir: WORKSPACE_DIR, |
|
|
configPath: configPath(), |
|
|
gatewayTokenFromEnv: Boolean( |
|
|
process.env.MOLTBOT_GATEWAY_TOKEN?.trim() || |
|
|
process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() |
|
|
), |
|
|
gatewayTokenPersisted: fs.existsSync(path.join(STATE_DIR, "gateway.token")), |
|
|
railwayCommit: process.env.RAILWAY_GIT_COMMIT_SHA || null, |
|
|
}, |
|
|
moltbot: { |
|
|
entry: MOLTBOT_ENTRY, |
|
|
node: MOLTBOT_NODE, |
|
|
version: v.output.trim(), |
|
|
channelsAddHelpIncludesTelegram: help.output.includes("telegram"), |
|
|
}, |
|
|
}); |
|
|
}); |
|
|
|
|
|
app.post("/setup/api/pairing/approve", requireSetupAuth, async (req, res) => { |
|
|
const { channel, code } = req.body || {}; |
|
|
if (!channel || !code) { |
|
|
return res.status(400).json({ ok: false, error: "Missing channel or code" }); |
|
|
} |
|
|
const r = await runCmd(MOLTBOT_NODE, moltArgs(["pairing", "approve", String(channel), String(code)])); |
|
|
return res.status(r.code === 0 ? 200 : 500).json({ ok: r.code === 0, output: r.output }); |
|
|
}); |
|
|
|
|
|
|
|
|
app.get("/setup/api/test", requireSetupAuth, (_req, res) => { |
|
|
console.error('[TEST] Test endpoint called'); |
|
|
res.json({ test: "ok", message: "Routing works!" }); |
|
|
}); |
|
|
|
|
|
|
|
|
app.get("/setup/api/debug", requireSetupAuth, async (_req, res) => { |
|
|
try { |
|
|
console.error('[DEBUG] Building debug info...'); |
|
|
|
|
|
const cfgPath = configPath(); |
|
|
console.error('[DEBUG] configPath():', cfgPath); |
|
|
|
|
|
const cfgExists = fs.existsSync(cfgPath); |
|
|
console.error('[DEBUG] config exists:', cfgExists); |
|
|
|
|
|
const stateExists = fs.existsSync(STATE_DIR); |
|
|
console.error('[DEBUG] state dir exists:', stateExists); |
|
|
|
|
|
const workspaceExists = fs.existsSync(WORKSPACE_DIR); |
|
|
console.error('[DEBUG] workspace dir exists:', workspaceExists); |
|
|
|
|
|
const configured = isConfigured(); |
|
|
console.error('[DEBUG] isConfigured():', configured); |
|
|
|
|
|
const info = { |
|
|
configPath: cfgPath, |
|
|
configExists: cfgExists, |
|
|
stateDir: STATE_DIR, |
|
|
stateDirExists: stateExists, |
|
|
workspaceDir: WORKSPACE_DIR, |
|
|
workspaceDirExists: workspaceExists, |
|
|
gatewayRunning: gatewayProc !== null, |
|
|
gatewayProcExists: gatewayProc !== null, |
|
|
gatewayProcPid: gatewayProc?.pid || null, |
|
|
configured: configured, |
|
|
}; |
|
|
|
|
|
console.error('[DEBUG] Built info object:', JSON.stringify(info)); |
|
|
|
|
|
if (info.configExists) { |
|
|
try { |
|
|
info.configContent = JSON.parse(fs.readFileSync(cfgPath, "utf8")); |
|
|
console.error('[DEBUG] Config content loaded'); |
|
|
} catch (e) { |
|
|
info.configError = String(e); |
|
|
console.error('[DEBUG] Config error:', e); |
|
|
} |
|
|
} |
|
|
|
|
|
console.error('[DEBUG] Sending response'); |
|
|
res.json(info); |
|
|
} catch (err) { |
|
|
console.error('[DEBUG] Error in debug endpoint:', err); |
|
|
res.status(500).json({ error: String(err), stack: err.stack }); |
|
|
} |
|
|
}); |
|
|
|
|
|
app.post("/setup/api/reset", requireSetupAuth, async (_req, res) => { |
|
|
|
|
|
|
|
|
try { |
|
|
fs.rmSync(configPath(), { force: true }); |
|
|
res.type("text/plain").send("OK - deleted config file. You can rerun setup now."); |
|
|
} catch (err) { |
|
|
res.status(500).type("text/plain").send(String(err)); |
|
|
} |
|
|
}); |
|
|
|
|
|
app.get("/setup/export", requireSetupAuth, async (_req, res) => { |
|
|
fs.mkdirSync(STATE_DIR, { recursive: true }); |
|
|
fs.mkdirSync(WORKSPACE_DIR, { recursive: true }); |
|
|
|
|
|
res.setHeader("content-type", "application/gzip"); |
|
|
res.setHeader( |
|
|
"content-disposition", |
|
|
`attachment; filename="moltbot-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.tar.gz"`, |
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
const stateAbs = path.resolve(STATE_DIR); |
|
|
const workspaceAbs = path.resolve(WORKSPACE_DIR); |
|
|
|
|
|
const dataRoot = "/data"; |
|
|
const underData = (p) => p === dataRoot || p.startsWith(dataRoot + path.sep); |
|
|
|
|
|
let cwd = "/"; |
|
|
let paths = [stateAbs, workspaceAbs].map((p) => p.replace(/^\//, "")); |
|
|
|
|
|
if (underData(stateAbs) && underData(workspaceAbs)) { |
|
|
cwd = dataRoot; |
|
|
|
|
|
paths = [ |
|
|
path.relative(dataRoot, stateAbs) || ".", |
|
|
path.relative(dataRoot, workspaceAbs) || ".", |
|
|
]; |
|
|
} |
|
|
|
|
|
const stream = tar.c( |
|
|
{ |
|
|
gzip: true, |
|
|
portable: true, |
|
|
noMtime: true, |
|
|
cwd, |
|
|
onwarn: () => {}, |
|
|
}, |
|
|
paths, |
|
|
); |
|
|
|
|
|
stream.on("error", (err) => { |
|
|
console.error("[export]", err); |
|
|
if (!res.headersSent) res.status(500); |
|
|
res.end(String(err)); |
|
|
}); |
|
|
|
|
|
stream.pipe(res); |
|
|
}); |
|
|
|
|
|
|
|
|
const proxy = httpProxy.createProxyServer({ |
|
|
target: GATEWAY_TARGET, |
|
|
ws: true, |
|
|
xfwd: true, |
|
|
}); |
|
|
|
|
|
proxy.on("error", (err, _req, _res) => { |
|
|
console.error("[proxy]", err); |
|
|
}); |
|
|
|
|
|
app.use(async (req, res) => { |
|
|
|
|
|
if (!isConfigured() && !req.path.startsWith("/setup")) { |
|
|
return res.redirect("/setup"); |
|
|
} |
|
|
|
|
|
if (isConfigured()) { |
|
|
try { |
|
|
await ensureGatewayRunning(); |
|
|
} catch (err) { |
|
|
return res.status(503).type("text/plain").send(`Gateway not ready: ${String(err)}`); |
|
|
} |
|
|
} |
|
|
|
|
|
return proxy.web(req, res, { target: GATEWAY_TARGET }); |
|
|
}); |
|
|
|
|
|
const server = app.listen(PORT, "0.0.0.0", () => { |
|
|
console.log(`[wrapper] listening on :${PORT}`); |
|
|
console.log(`[wrapper] state dir: ${STATE_DIR}`); |
|
|
console.log(`[wrapper] workspace dir: ${WORKSPACE_DIR}`); |
|
|
console.log(`[wrapper] gateway token: ${MOLTBOT_GATEWAY_TOKEN ? "(set)" : "(missing)"}`); |
|
|
console.log(`[wrapper] gateway target: ${GATEWAY_TARGET}`); |
|
|
if (!SETUP_PASSWORD) { |
|
|
console.warn("[wrapper] WARNING: SETUP_PASSWORD is not set; /setup will error."); |
|
|
} |
|
|
|
|
|
}); |
|
|
|
|
|
server.on("upgrade", async (req, socket, head) => { |
|
|
if (!isConfigured()) { |
|
|
socket.destroy(); |
|
|
return; |
|
|
} |
|
|
try { |
|
|
await ensureGatewayRunning(); |
|
|
} catch { |
|
|
socket.destroy(); |
|
|
return; |
|
|
} |
|
|
proxy.ws(req, socket, head, { target: GATEWAY_TARGET }); |
|
|
}); |
|
|
|
|
|
process.on("SIGTERM", () => { |
|
|
|
|
|
try { |
|
|
if (gatewayProc) gatewayProc.kill("SIGTERM"); |
|
|
} catch { |
|
|
|
|
|
} |
|
|
process.exit(0); |
|
|
}); |
|
|
|