mirror of
https://github.com/arjunkomath/openclaw-railway-template.git
synced 2026-08-14 00:48:11 +00:00
Improve setup
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
[tools]
|
||||||
|
node = "24"
|
||||||
@@ -25,8 +25,8 @@ npm start
|
|||||||
# Syntax check
|
# Syntax check
|
||||||
npm run lint
|
npm run lint
|
||||||
|
|
||||||
# Local smoke test (requires Docker)
|
# Smoke test in Docker (scripts not included in production image)
|
||||||
npm run smoke
|
docker run --rm -v $(pwd)/scripts:/app/scripts openclaw-railway-template npm run smoke
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker Build & Local Testing
|
## Docker Build & Local Testing
|
||||||
|
|||||||
+11
-2
@@ -1,5 +1,5 @@
|
|||||||
# Build openclaw from source to avoid npm packaging gaps (some dist files are not shipped).
|
# Build openclaw from source to avoid npm packaging gaps (some dist files are not shipped).
|
||||||
FROM node:22-bookworm AS openclaw-build
|
FROM node:24.1.0-bookworm AS openclaw-build
|
||||||
|
|
||||||
# Dependencies needed for openclaw build
|
# Dependencies needed for openclaw build
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
@@ -39,7 +39,7 @@ RUN pnpm ui:install && pnpm ui:build
|
|||||||
|
|
||||||
|
|
||||||
# Runtime image
|
# Runtime image
|
||||||
FROM node:22-bookworm
|
FROM node:24.1.0-bookworm
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
@@ -87,6 +87,15 @@ RUN printf '%s\n' '#!/usr/bin/env bash' 'exec node /openclaw/dist/entry.js "$@"'
|
|||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
|
||||||
|
RUN useradd -m -s /bin/bash openclaw \
|
||||||
|
&& chown -R openclaw:openclaw /app /openclaw \
|
||||||
|
&& mkdir -p /data && chown openclaw:openclaw /data
|
||||||
|
|
||||||
ENV PORT=8080
|
ENV PORT=8080
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
|
||||||
|
CMD curl -f http://localhost:8080/setup/healthz || exit 1
|
||||||
|
|
||||||
|
USER openclaw
|
||||||
CMD ["node", "src/server.js"]
|
CMD ["node", "src/server.js"]
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node src/server.js",
|
"dev": "node src/server.js",
|
||||||
|
|||||||
+65
-6
@@ -1,8 +1,67 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
|
||||||
const r = spawnSync("openclaw", ["--version"], { encoding: "utf8" });
|
const PORT = process.env.PORT || 8080;
|
||||||
if (r.status !== 0) {
|
|
||||||
console.error(r.stdout || r.stderr);
|
const versionCheck = spawnSync("openclaw", ["--version"], { encoding: "utf8" });
|
||||||
process.exit(r.status ?? 1);
|
if (versionCheck.status !== 0) {
|
||||||
|
console.error(versionCheck.stdout || versionCheck.stderr);
|
||||||
|
process.exit(versionCheck.status ?? 1);
|
||||||
}
|
}
|
||||||
console.log("openclaw ok:", r.stdout.trim());
|
console.log("✓ openclaw version:", versionCheck.stdout.trim());
|
||||||
|
|
||||||
|
console.log(`Starting server on port ${PORT}...`);
|
||||||
|
const serverProc = spawn("node", ["src/server.js"], {
|
||||||
|
env: { ...process.env, PORT: String(PORT), SETUP_PASSWORD: "smoke-test" },
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let serverOutput = "";
|
||||||
|
serverProc.stdout.on("data", (d) => (serverOutput += d.toString()));
|
||||||
|
serverProc.stderr.on("data", (d) => (serverOutput += d.toString()));
|
||||||
|
|
||||||
|
async function waitForServer(maxWaitMs = 10000) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < maxWaitMs) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:${PORT}/setup/healthz`);
|
||||||
|
if (res.ok) return true;
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTests() {
|
||||||
|
try {
|
||||||
|
const ready = await waitForServer();
|
||||||
|
if (!ready) {
|
||||||
|
console.error("✗ Server did not become ready");
|
||||||
|
console.error("Server output:", serverOutput);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("✓ Server started on port", PORT);
|
||||||
|
|
||||||
|
const healthRes = await fetch(`http://localhost:${PORT}/setup/healthz`);
|
||||||
|
if (!healthRes.ok) {
|
||||||
|
console.error("✗ /setup/healthz returned", healthRes.status);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const healthBody = await healthRes.json();
|
||||||
|
if (!healthBody.ok) {
|
||||||
|
console.error("✗ /setup/healthz returned unexpected body:", healthBody);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("✓ /setup/healthz returns 200 with { ok: true }");
|
||||||
|
|
||||||
|
console.log("\n✓ All smoke tests passed");
|
||||||
|
} finally {
|
||||||
|
serverProc.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests().catch((err) => {
|
||||||
|
console.error("Smoke test error:", err);
|
||||||
|
serverProc.kill("SIGTERM");
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
+84
-119
@@ -26,14 +26,16 @@ function resolveGatewayToken() {
|
|||||||
try {
|
try {
|
||||||
const existing = fs.readFileSync(tokenPath, "utf8").trim();
|
const existing = fs.readFileSync(tokenPath, "utf8").trim();
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn(`[gateway-token] could not read existing token: ${err.code || err.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const generated = crypto.randomBytes(32).toString("hex");
|
const generated = crypto.randomBytes(32).toString("hex");
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(STATE_DIR, { recursive: true });
|
fs.mkdirSync(STATE_DIR, { recursive: true });
|
||||||
fs.writeFileSync(tokenPath, generated, { encoding: "utf8", mode: 0o600 });
|
fs.writeFileSync(tokenPath, generated, { encoding: "utf8", mode: 0o600 });
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn(`[gateway-token] could not persist token: ${err.code || err.message}`);
|
||||||
}
|
}
|
||||||
return generated;
|
return generated;
|
||||||
}
|
}
|
||||||
@@ -92,6 +94,9 @@ async function waitForGatewayReady(opts = {}) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err.code !== "ECONNREFUSED") {
|
||||||
|
console.warn(`[gateway] health check error: ${err.code || err.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await sleep(250);
|
await sleep(250);
|
||||||
@@ -167,7 +172,8 @@ async function restartGateway() {
|
|||||||
if (gatewayProc) {
|
if (gatewayProc) {
|
||||||
try {
|
try {
|
||||||
gatewayProc.kill("SIGTERM");
|
gatewayProc.kill("SIGTERM");
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn(`[gateway] kill error: ${err.message}`);
|
||||||
}
|
}
|
||||||
await sleep(750);
|
await sleep(750);
|
||||||
gatewayProc = null;
|
gatewayProc = null;
|
||||||
@@ -194,7 +200,11 @@ function requireSetupAuth(req, res, next) {
|
|||||||
const decoded = Buffer.from(encoded, "base64").toString("utf8");
|
const decoded = Buffer.from(encoded, "base64").toString("utf8");
|
||||||
const idx = decoded.indexOf(":");
|
const idx = decoded.indexOf(":");
|
||||||
const password = idx >= 0 ? decoded.slice(idx + 1) : "";
|
const password = idx >= 0 ? decoded.slice(idx + 1) : "";
|
||||||
if (password !== SETUP_PASSWORD) {
|
const passwordBuf = Buffer.from(password);
|
||||||
|
const expectedBuf = Buffer.from(SETUP_PASSWORD);
|
||||||
|
const isValid = passwordBuf.length === expectedBuf.length &&
|
||||||
|
crypto.timingSafeEqual(passwordBuf, expectedBuf);
|
||||||
|
if (!isValid) {
|
||||||
res.set("WWW-Authenticate", 'Basic realm="OpenClaw Setup"');
|
res.set("WWW-Authenticate", 'Basic realm="OpenClaw Setup"');
|
||||||
return res.status(401).send("Invalid password");
|
return res.status(401).send("Invalid password");
|
||||||
}
|
}
|
||||||
@@ -417,6 +427,34 @@ function runCmd(cmd, args, opts = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_FLOWS = ["quickstart", "advanced", "manual"];
|
||||||
|
const VALID_AUTH_CHOICES = [
|
||||||
|
"codex-cli", "openai-codex", "openai-api-key",
|
||||||
|
"claude-cli", "token", "apiKey",
|
||||||
|
"gemini-api-key", "google-antigravity", "google-gemini-cli",
|
||||||
|
"openrouter-api-key", "ai-gateway-api-key",
|
||||||
|
"moonshot-api-key", "kimi-code-api-key",
|
||||||
|
"zai-api-key", "minimax-api", "minimax-api-lightning",
|
||||||
|
"qwen-portal", "github-copilot", "copilot-proxy",
|
||||||
|
"synthetic-api-key", "opencode-zen",
|
||||||
|
];
|
||||||
|
|
||||||
|
function validatePayload(payload) {
|
||||||
|
if (payload.flow && !VALID_FLOWS.includes(payload.flow)) {
|
||||||
|
return `Invalid flow: ${payload.flow}. Must be one of: ${VALID_FLOWS.join(", ")}`;
|
||||||
|
}
|
||||||
|
if (payload.authChoice && !VALID_AUTH_CHOICES.includes(payload.authChoice)) {
|
||||||
|
return `Invalid authChoice: ${payload.authChoice}`;
|
||||||
|
}
|
||||||
|
const stringFields = ["telegramToken", "discordToken", "slackBotToken", "slackAppToken", "authSecret"];
|
||||||
|
for (const field of stringFields) {
|
||||||
|
if (payload[field] !== undefined && typeof payload[field] !== "string") {
|
||||||
|
return `Invalid ${field}: must be a string`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
app.post("/setup/api/run", requireSetupAuth, async (req, res) => {
|
app.post("/setup/api/run", requireSetupAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (isConfigured()) {
|
if (isConfigured()) {
|
||||||
@@ -432,6 +470,10 @@ app.post("/setup/api/run", requireSetupAuth, async (req, res) => {
|
|||||||
fs.mkdirSync(WORKSPACE_DIR, { recursive: true });
|
fs.mkdirSync(WORKSPACE_DIR, { recursive: true });
|
||||||
|
|
||||||
const payload = req.body || {};
|
const payload = req.body || {};
|
||||||
|
const validationError = validatePayload(payload);
|
||||||
|
if (validationError) {
|
||||||
|
return res.status(400).json({ ok: false, output: validationError });
|
||||||
|
}
|
||||||
const onboardArgs = buildOnboardArgs(payload);
|
const onboardArgs = buildOnboardArgs(payload);
|
||||||
const onboard = await runCmd(OPENCLAW_NODE, clawArgs(onboardArgs));
|
const onboard = await runCmd(OPENCLAW_NODE, clawArgs(onboardArgs));
|
||||||
|
|
||||||
@@ -440,33 +482,6 @@ app.post("/setup/api/run", requireSetupAuth, async (req, res) => {
|
|||||||
const ok = onboard.code === 0 && isConfigured();
|
const ok = onboard.code === 0 && isConfigured();
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
await runCmd(OPENCLAW_NODE, clawArgs(["config", "set", "gateway.mode", "local"]));
|
|
||||||
await runCmd(
|
|
||||||
OPENCLAW_NODE,
|
|
||||||
clawArgs(["config", "set", "gateway.auth.mode", "token"]),
|
|
||||||
);
|
|
||||||
await runCmd(
|
|
||||||
OPENCLAW_NODE,
|
|
||||||
clawArgs([
|
|
||||||
"config",
|
|
||||||
"set",
|
|
||||||
"gateway.auth.token",
|
|
||||||
OPENCLAW_GATEWAY_TOKEN,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
await runCmd(
|
|
||||||
OPENCLAW_NODE,
|
|
||||||
clawArgs(["config", "set", "gateway.bind", "loopback"]),
|
|
||||||
);
|
|
||||||
await runCmd(
|
|
||||||
OPENCLAW_NODE,
|
|
||||||
clawArgs([
|
|
||||||
"config",
|
|
||||||
"set",
|
|
||||||
"gateway.port",
|
|
||||||
String(INTERNAL_GATEWAY_PORT),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
await runCmd(
|
await runCmd(
|
||||||
OPENCLAW_NODE,
|
OPENCLAW_NODE,
|
||||||
clawArgs(["config", "set", "gateway.controlUi.allowInsecureAuth", "true"]),
|
clawArgs(["config", "set", "gateway.controlUi.allowInsecureAuth", "true"]),
|
||||||
@@ -478,100 +493,47 @@ app.post("/setup/api/run", requireSetupAuth, async (req, res) => {
|
|||||||
);
|
);
|
||||||
const helpText = channelsHelp.output || "";
|
const helpText = channelsHelp.output || "";
|
||||||
|
|
||||||
const supports = (name) => helpText.includes(name);
|
async function configureChannel(name, cfgObj) {
|
||||||
|
if (!helpText.includes(name)) {
|
||||||
|
return `\n[${name}] skipped (this openclaw build does not list ${name} in \`channels add --help\`)\n`;
|
||||||
|
}
|
||||||
|
const set = await runCmd(
|
||||||
|
OPENCLAW_NODE,
|
||||||
|
clawArgs(["config", "set", "--json", `channels.${name}`, JSON.stringify(cfgObj)]),
|
||||||
|
);
|
||||||
|
const get = await runCmd(
|
||||||
|
OPENCLAW_NODE,
|
||||||
|
clawArgs(["config", "get", `channels.${name}`]),
|
||||||
|
);
|
||||||
|
return `\n[${name} config] exit=${set.code} (output ${set.output.length} chars)\n${set.output || "(no output)"}` +
|
||||||
|
`\n[${name} verify] exit=${get.code} (output ${get.output.length} chars)\n${get.output || "(no output)"}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.telegramToken?.trim()) {
|
if (payload.telegramToken?.trim()) {
|
||||||
if (!supports("telegram")) {
|
extra += await configureChannel("telegram", {
|
||||||
extra +=
|
enabled: true,
|
||||||
"\n[telegram] skipped (this openclaw build does not list telegram in `channels add --help`)\n";
|
dmPolicy: "pairing",
|
||||||
} else {
|
botToken: payload.telegramToken.trim(),
|
||||||
const token = payload.telegramToken.trim();
|
groupPolicy: "allowlist",
|
||||||
const cfgObj = {
|
streamMode: "partial",
|
||||||
enabled: true,
|
});
|
||||||
dmPolicy: "pairing",
|
|
||||||
botToken: token,
|
|
||||||
groupPolicy: "allowlist",
|
|
||||||
streamMode: "partial",
|
|
||||||
};
|
|
||||||
const set = await runCmd(
|
|
||||||
OPENCLAW_NODE,
|
|
||||||
clawArgs([
|
|
||||||
"config",
|
|
||||||
"set",
|
|
||||||
"--json",
|
|
||||||
"channels.telegram",
|
|
||||||
JSON.stringify(cfgObj),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
const get = await runCmd(
|
|
||||||
OPENCLAW_NODE,
|
|
||||||
clawArgs(["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 (payload.discordToken?.trim()) {
|
||||||
if (!supports("discord")) {
|
extra += await configureChannel("discord", {
|
||||||
extra +=
|
enabled: true,
|
||||||
"\n[discord] skipped (this openclaw build does not list discord in `channels add --help`)\n";
|
token: payload.discordToken.trim(),
|
||||||
} else {
|
groupPolicy: "allowlist",
|
||||||
const token = payload.discordToken.trim();
|
dm: { policy: "pairing" },
|
||||||
const cfgObj = {
|
});
|
||||||
enabled: true,
|
|
||||||
token,
|
|
||||||
groupPolicy: "allowlist",
|
|
||||||
dm: {
|
|
||||||
policy: "pairing",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const set = await runCmd(
|
|
||||||
OPENCLAW_NODE,
|
|
||||||
clawArgs([
|
|
||||||
"config",
|
|
||||||
"set",
|
|
||||||
"--json",
|
|
||||||
"channels.discord",
|
|
||||||
JSON.stringify(cfgObj),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
const get = await runCmd(
|
|
||||||
OPENCLAW_NODE,
|
|
||||||
clawArgs(["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 (payload.slackBotToken?.trim() || payload.slackAppToken?.trim()) {
|
||||||
if (!supports("slack")) {
|
extra += await configureChannel("slack", {
|
||||||
extra +=
|
enabled: true,
|
||||||
"\n[slack] skipped (this openclaw build does not list slack in `channels add --help`)\n";
|
botToken: payload.slackBotToken?.trim() || undefined,
|
||||||
} else {
|
appToken: payload.slackAppToken?.trim() || undefined,
|
||||||
const cfgObj = {
|
});
|
||||||
enabled: true,
|
|
||||||
botToken: payload.slackBotToken?.trim() || undefined,
|
|
||||||
appToken: payload.slackAppToken?.trim() || undefined,
|
|
||||||
};
|
|
||||||
const set = await runCmd(
|
|
||||||
OPENCLAW_NODE,
|
|
||||||
clawArgs([
|
|
||||||
"config",
|
|
||||||
"set",
|
|
||||||
"--json",
|
|
||||||
"channels.slack",
|
|
||||||
JSON.stringify(cfgObj),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
const get = await runCmd(
|
|
||||||
OPENCLAW_NODE,
|
|
||||||
clawArgs(["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)"}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await restartGateway();
|
await restartGateway();
|
||||||
@@ -741,7 +703,8 @@ server.on("upgrade", async (req, socket, head) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await ensureGatewayRunning();
|
await ensureGatewayRunning();
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn(`[websocket] gateway not ready: ${err.message}`);
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -749,9 +712,11 @@ server.on("upgrade", async (req, socket, head) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
|
console.log("[wrapper] received SIGTERM, shutting down");
|
||||||
try {
|
try {
|
||||||
if (gatewayProc) gatewayProc.kill("SIGTERM");
|
if (gatewayProc) gatewayProc.kill("SIGTERM");
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.warn(`[wrapper] error killing gateway: ${err.message}`);
|
||||||
}
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user