Files
u-claw/portable/config-server/server.js
hfshfg 6ab87c6b67 fix(portable): 微信扫码重定向/Telegram botToken/Windows 启动端口 (v2.1.11)
- 微信扫码卡死: config-server 跟随 IDC scaned_but_redirect 把后续轮询切到
  redirect_host,刷新二维码时重置 pollBaseUrl(扫码后不再卡住等不到 confirmed)。新增测试。
- Telegram 渠道改用 botToken(顶层 telegram 只认 botToken,旧 token 别名被静默忽略 → 连不上)。
- Windows-Start: 先起 config-server 再探测 gateway 端口,从 runtime.json 读实际
  configServerPort 并按该端口开 Config Center / 传给 wait-gateway(18788 被占也能开)。
- wait-gateway.bat: 用 ping 代替 timeout /t(避开 PATH 上 GNU coreutils timeout 冲突),接受 CONFIG_PORT 参数。
- Windows-Menu: IF 块内 echo 的 () 转义为 ^(^),修 v2.1.10 闪退回归。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 00:41:20 +08:00

572 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
const http = require('http');
const fs = require('fs');
const path = require('path');
const { deflateSync } = require('zlib');
const crypto = require('crypto');
const PORT_RANGE_START = 18788;
const PORT_RANGE_END = 18798;
const CONFIG_PATH = path.join(__dirname, '../data/.openclaw/openclaw.json');
const RUNTIME_PATH = path.join(__dirname, '../data/.openclaw/runtime.json');
// ── WeChat Login State ──────────────────────────────────────────────────────
const DEFAULT_WECHAT_BASE_URL = 'https://ilinkai.weixin.qq.com';
const DEFAULT_ILINK_BOT_TYPE = '3';
const ACTIVE_LOGIN_TTL_MS = 5 * 60000;
const QR_POLL_TIMEOUT_MS = 35000;
const MAX_QR_REFRESH_COUNT = 3;
// Resolve ~/.openclaw/ directory
const OPENCLAW_DIR = process.env.OPENCLAW_STATE_DIR ||
path.join(process.env.USERPROFILE || process.env.HOME || require('os').homedir(), '.openclaw');
const WECHAT_STATE_DIR = path.join(OPENCLAW_DIR, 'openclaw-weixin');
const WECHAT_ACCOUNTS_DIR = path.join(WECHAT_STATE_DIR, 'accounts');
const WECHAT_ACCOUNT_INDEX_FILE = path.join(WECHAT_STATE_DIR, 'accounts.json');
// Plugin source on USB
const USB_PLUGIN_DIR = path.join(__dirname, '../app/extensions/openclaw-weixin');
const INSTALLED_PLUGIN_DIR = path.join(OPENCLAW_DIR, 'extensions', 'openclaw-weixin');
const activeLogins = new Map();
// ── QR Code PNG Renderer (pure Node.js, no external deps) ───────────────────
function getQrRenderDeps() {
// Try to load QR lib from openclaw's bundled qrcode-terminal
const corePath = path.join(__dirname, '../app/core/node_modules');
const candidates = [
path.join(corePath, 'qrcode-terminal/vendor/QRCode/index.js'),
path.join(corePath, 'openclaw/node_modules/qrcode-terminal/vendor/QRCode/index.js'),
];
const errCandidates = [
path.join(corePath, 'qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js'),
path.join(corePath, 'openclaw/node_modules/qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js'),
];
for (let i = 0; i < candidates.length; i++) {
if (fs.existsSync(candidates[i])) {
return { QRCode: require(candidates[i]), QRErrorCorrectLevel: require(errCandidates[i]) };
}
}
// Fallback: try WeChat plugin's own node_modules
const pluginQr = path.join(USB_PLUGIN_DIR, 'node_modules/qrcode-terminal/vendor/QRCode/index.js');
const pluginQrErr = path.join(USB_PLUGIN_DIR, 'node_modules/qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js');
if (fs.existsSync(pluginQr)) {
return { QRCode: require(pluginQr), QRErrorCorrectLevel: require(pluginQrErr) };
}
throw new Error('QR code library not found');
}
function createQrMatrix(input) {
const { QRCode, QRErrorCorrectLevel } = getQrRenderDeps();
const qr = new QRCode(-1, QRErrorCorrectLevel.L);
qr.addData(input);
qr.make();
return qr;
}
function fillPixel(buf, x, y, width, r, g, b, a) {
const idx = (y * width + x) * 4;
buf[idx] = r; buf[idx + 1] = g; buf[idx + 2] = b; buf[idx + 3] = (a === undefined ? 255 : a);
}
const CRC_TABLE = (function() {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[i] = c >>> 0;
}
return table;
})();
function crc32(buf) {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const typeBuf = Buffer.from(type, 'ascii');
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
const crc = crc32(Buffer.concat([typeBuf, data]));
const crcBuf = Buffer.alloc(4); crcBuf.writeUInt32BE(crc, 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
function encodePngRgba(buffer, width, height) {
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let row = 0; row < height; row++) {
const offset = row * (stride + 1);
raw[offset] = 0;
buffer.copy(raw, offset + 1, row * stride, row * stride + stride);
}
const compressed = deflateSync(raw);
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; ihdr[9] = 6;
return Buffer.concat([signature, pngChunk('IHDR', ihdr), pngChunk('IDAT', compressed), pngChunk('IEND', Buffer.alloc(0))]);
}
function renderQrPngDataUrl(input) {
const scale = 6, margin = 4;
const qr = createQrMatrix(input);
const modules = qr.getModuleCount();
const size = (modules + margin * 2) * scale;
const buf = Buffer.alloc(size * size * 4, 255);
for (let row = 0; row < modules; row++) {
for (let col = 0; col < modules; col++) {
if (!qr.isDark(row, col)) continue;
const sx = (col + margin) * scale, sy = (row + margin) * scale;
for (let y = 0; y < scale; y++) for (let x = 0; x < scale; x++)
fillPixel(buf, sx + x, sy + y, size, 0, 0, 0, 255);
}
}
return 'data:image/png;base64,' + encodePngRgba(buf, size, size).toString('base64');
}
// ── WeChat API helpers ──────────────────────────────────────────────────────
async function fetchWeChatQrCode(apiBaseUrl) {
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : apiBaseUrl + '/';
const url = base + 'ilink/bot/get_bot_qrcode?bot_type=' + encodeURIComponent(DEFAULT_ILINK_BOT_TYPE);
const response = await fetch(url);
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error('Failed to fetch QR: ' + response.status + ' ' + body);
}
return await response.json();
}
async function pollWeChatQrStatus(apiBaseUrl, qrcode) {
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : apiBaseUrl + '/';
const url = base + 'ilink/bot/get_qrcode_status?qrcode=' + encodeURIComponent(qrcode);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), QR_POLL_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: { 'iLink-App-ClientVersion': '1' },
signal: controller.signal
});
clearTimeout(timer);
const text = await response.text();
if (!response.ok) throw new Error('Poll failed: ' + response.status + ' ' + text);
return JSON.parse(text);
} catch (err) {
clearTimeout(timer);
if (err.name === 'AbortError') return { status: 'wait' };
throw err;
}
}
function normalizeAccountId(raw) {
return String(raw).toLowerCase().replace(/[^a-z0-9._-]/g, '-');
}
async function saveWeChatAccount(rawAccountId, payload) {
const accountId = normalizeAccountId(rawAccountId);
fs.mkdirSync(WECHAT_ACCOUNTS_DIR, { recursive: true });
const filePath = path.join(WECHAT_ACCOUNTS_DIR, accountId + '.json');
const data = {
token: payload.token.trim(),
savedAt: new Date().toISOString(),
};
if (payload.baseUrl) data.baseUrl = payload.baseUrl.trim();
if (payload.userId) data.userId = payload.userId.trim();
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
// Update account index
let accounts = [];
try { accounts = JSON.parse(fs.readFileSync(WECHAT_ACCOUNT_INDEX_FILE, 'utf-8')); } catch {}
if (!Array.isArray(accounts)) accounts = [];
if (!accounts.includes(accountId)) {
accounts.push(accountId);
fs.mkdirSync(WECHAT_STATE_DIR, { recursive: true });
fs.writeFileSync(WECHAT_ACCOUNT_INDEX_FILE, JSON.stringify(accounts, null, 2));
}
return accountId;
}
function ensureWeChatPluginInstalled() {
if (!fs.existsSync(USB_PLUGIN_DIR) || !fs.existsSync(path.join(USB_PLUGIN_DIR, 'openclaw.plugin.json'))) {
return { installed: false, warning: 'WeChat plugin not found on USB' };
}
if (fs.existsSync(path.join(INSTALLED_PLUGIN_DIR, 'openclaw.plugin.json'))) {
return { installed: true };
}
// Copy from USB to ~/.openclaw/extensions/
// 容错copy 失败不抛错中断整个 confirmed 流程(账号保存 + openclaw.json 已/将写好)。
try {
const extDir = path.join(OPENCLAW_DIR, 'extensions');
fs.mkdirSync(extDir, { recursive: true });
copyDirSync(USB_PLUGIN_DIR, INSTALLED_PLUGIN_DIR);
} catch (e) {
console.error('WeChat plugin copy failed:', e.message);
return { installed: false, warning: e.message };
}
return { installed: fs.existsSync(path.join(INSTALLED_PLUGIN_DIR, 'openclaw.plugin.json')) };
}
function copyDirSync(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
if (entry.isDirectory()) copyDirSync(s, d);
else fs.copyFileSync(s, d);
}
}
// ── WeChat login session management ─────────────────────────────────────────
async function handleWeChatStart() {
const sessionKey = crypto.randomUUID();
const apiBaseUrl = DEFAULT_WECHAT_BASE_URL;
const qrResponse = await fetchWeChatQrCode(apiBaseUrl);
const qrDataUrl = renderQrPngDataUrl(qrResponse.qrcode_img_content);
activeLogins.set(sessionKey, {
sessionKey,
qrcode: qrResponse.qrcode,
qrcodeUrl: qrDataUrl,
startedAt: Date.now(),
apiBaseUrl,
});
return { sessionKey, qrcodeUrl: qrDataUrl };
}
async function handleWeChatStatus(sessionKey) {
const login = activeLogins.get(sessionKey);
if (!login) return { status: 'expired', message: 'No active session' };
if (Date.now() - login.startedAt > ACTIVE_LOGIN_TTL_MS) {
activeLogins.delete(sessionKey);
return { status: 'expired', message: 'Session expired' };
}
// 状态轮询用 pollBaseUrlIDC 重定向后会指向新主机);二维码获取/刷新始终用原始
// apiBaseUrl与官方插件一致refresh 回到固定主机,只有 status 轮询跟随重定向)。
const result = await pollWeChatQrStatus(login.pollBaseUrl || login.apiBaseUrl, login.qrcode);
// 微信登录状态流转日志(跳过高频的 wait便于排查"扫码卡死"类问题)。
if (result.status && result.status !== 'wait') {
console.log(`[wechat] status=${result.status}` + (result.redirect_host ? ` redirect_host=${result.redirect_host}` : ''));
}
if (result.status === 'expired') {
// Try to refresh QR code
if (!login.refreshCount) login.refreshCount = 1;
login.refreshCount++;
if (login.refreshCount > MAX_QR_REFRESH_COUNT) {
activeLogins.delete(sessionKey);
return { status: 'expired', message: 'QR expired too many times' };
}
const refreshed = await fetchWeChatQrCode(login.apiBaseUrl);
const newQr = renderQrPngDataUrl(refreshed.qrcode_img_content);
login.qrcode = refreshed.qrcode;
login.qrcodeUrl = newQr;
login.startedAt = Date.now();
// 新二维码来自原始主机,重置轮询主机,避免拿新码去轮询旧的重定向主机。
login.pollBaseUrl = null;
return { status: 'refreshed', qrcodeUrl: newQr };
}
if (result.status === 'confirmed') {
activeLogins.delete(sessionKey);
if (!result.ilink_bot_id || !result.bot_token) {
return { status: 'error', message: 'Server did not return credentials' };
}
// 1. Install plugin
const pluginResult = ensureWeChatPluginInstalled();
// 2. Save account
const accountId = await saveWeChatAccount(result.ilink_bot_id, {
token: result.bot_token,
baseUrl: result.baseurl,
userId: result.ilink_user_id,
});
// 3. Update openclaw.json to enable the plugin
try {
const configRaw = fs.existsSync(CONFIG_PATH) ? fs.readFileSync(CONFIG_PATH, 'utf-8') : '{}';
const config = JSON.parse(configRaw);
if (!config.plugins) config.plugins = {};
if (!config.plugins.entries) config.plugins.entries = {};
config.plugins.entries['openclaw-weixin'] = { enabled: true };
const dir = path.dirname(CONFIG_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
} catch (e) {
console.error('Failed to update config:', e.message);
}
return {
status: 'confirmed',
accountId,
pluginInstalled: pluginResult.installed,
message: 'WeChat connected! Restart Gateway to activate.',
};
}
// IDC 重定向用户扫码后ilink 服务端可能要求把后续轮询切换到另一个数据中心主机
// (status=scaned_but_redirect + redirect_host)。必须跟着切,否则一直轮询旧主机,
// 扫码后永远等不到 confirmed——表现为「扫了码却卡死不前进」。
// 同款逻辑见官方插件 openclaw-weixin/src/auth/login-qr.ts 的 scaned_but_redirect 分支。
if (result.status === 'scaned_but_redirect') {
if (result.redirect_host) {
login.pollBaseUrl = 'https://' + result.redirect_host;
}
// 对前端按「已扫码」处理:显示提示并继续轮询,下一轮已指向新主机。
return { status: 'scaned' };
}
return { status: result.status };
}
function handleWeChatCancel(sessionKey) {
if (sessionKey) activeLogins.delete(sessionKey);
else activeLogins.clear();
}
const server = http.createServer((req, res) => {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// API: WeChat start login
if (req.url === '/api/wechat/start' && req.method === 'POST') {
handleWeChatStart()
.then(result => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
})
.catch(err => {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
});
return;
}
// API: WeChat poll status
if (req.url && req.url.startsWith('/api/wechat/status') && req.method === 'GET') {
const urlObj = new URL(req.url, 'http://localhost');
const session = urlObj.searchParams.get('session');
if (!session) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing session parameter' }));
return;
}
handleWeChatStatus(session)
.then(result => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
})
.catch(err => {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
});
return;
}
// API: WeChat cancel
if (req.url === '/api/wechat/cancel' && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const data = body ? JSON.parse(body) : {};
handleWeChatCancel(data.session);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});
return;
}
// API: WeChat plugin status
if (req.url === '/api/wechat/plugin-status' && req.method === 'GET') {
const hasPlugin = fs.existsSync(path.join(USB_PLUGIN_DIR, 'openclaw.plugin.json'));
const installed = fs.existsSync(path.join(INSTALLED_PLUGIN_DIR, 'openclaw.plugin.json'));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ hasPlugin, installed }));
return;
}
// API: Get config
if (req.url === '/api/config' && req.method === 'GET') {
try {
const config = fs.existsSync(CONFIG_PATH)
? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
: {};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(config));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return;
}
// API: Update status — read update-available.json written by check-update.mjs
// Returns { available: false } if no info or stale; otherwise the manifest payload.
if (req.url === '/api/update-status' && req.method === 'GET') {
try {
const stateDir = process.env.OPENCLAW_STATE_DIR
|| path.join(__dirname, '../data/.openclaw');
const updateFile = path.join(stateDir, 'update-available.json');
if (!fs.existsSync(updateFile)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ available: false, reason: 'no-check-yet' }));
return;
}
const payload = JSON.parse(fs.readFileSync(updateFile, 'utf8'));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
} catch (err) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ available: false, reason: 'read-failed', error: err.message }));
}
return;
}
// API: Trigger update check on demand (so users can press a "Check now" button)
if (req.url === '/api/update-check' && req.method === 'POST') {
(async () => {
try {
const mod = await import('../lib/check-update.mjs');
const portableRoot = path.join(__dirname, '..');
const versionFilePath = fs.existsSync(path.join(portableRoot, 'OPENCLAW_VERSION'))
? path.join(portableRoot, 'OPENCLAW_VERSION')
: path.join(portableRoot, '..', 'OPENCLAW_VERSION');
const stateDir = process.env.OPENCLAW_STATE_DIR
|| path.join(portableRoot, 'data/.openclaw');
const result = await mod.checkUpdate({ versionFilePath, stateDir });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
})();
return;
}
// API: Discover local models (Ollama / LM Studio)
// 借鉴 RealShocky/openclaw-windows自动探测本机已装的本地模型
// 用户无需手填 baseUrl/模型名,直接点选即可(便携版纯离线推理卖点)。
// 静默失败:探测不到就返回空数组,不影响 Config 页面。
if (req.url === '/api/local-models' && req.method === 'GET') {
(async () => {
const probes = [
{ provider: 'ollama', label: 'Ollama', base: 'http://127.0.0.1:11434/v1', api: 'http://127.0.0.1:11434/api/tags' },
{ provider: 'lmstudio', label: 'LM Studio', base: 'http://127.0.0.1:1234/v1', api: 'http://127.0.0.1:1234/v1/models' },
];
const found = [];
await Promise.all(probes.map(async (p) => {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 1200);
const r = await fetch(p.api, { signal: ctrl.signal });
clearTimeout(t);
if (!r.ok) return;
const data = await r.json();
// Ollama: { models:[{name}] } | LM Studio (OpenAI-style): { data:[{id}] }
const models = Array.isArray(data.models)
? data.models.map(m => m.name).filter(Boolean)
: Array.isArray(data.data)
? data.data.map(m => m.id).filter(Boolean)
: [];
if (models.length) found.push({ provider: p.provider, label: p.label, base: p.base, models });
} catch { /* 探测失败:该 provider 未运行,跳过 */ }
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ providers: found }));
})();
return;
}
// API: Save config
if (req.url === '/api/config' && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const config = JSON.parse(body);
// 清除旧版废弃键,防止 OpenClaw 报 "agent.* was moved" 错误
delete config.agent;
const dir = path.dirname(CONFIG_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});
return;
}
// Serve static files
const filePath = req.url === '/'
? path.join(__dirname, 'public/index.html')
: path.join(__dirname, 'public', req.url);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath);
const contentType = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json'
}[ext] || 'text/plain';
res.writeHead(200, { 'Content-Type': contentType });
fs.createReadStream(filePath).pipe(res);
} else {
res.writeHead(404);
res.end('Not Found');
}
});
function listenWithFallback(port) {
server.once('error', (err) => {
if (err && err.code === 'EADDRINUSE' && port < PORT_RANGE_END) {
console.log(` Port ${port} busy, trying ${port + 1}`);
setImmediate(() => listenWithFallback(port + 1));
return;
}
console.error(`Config server failed to bind: ${err && err.message ? err.message : err}`);
process.exit(1);
});
server.listen(port, '127.0.0.1', () => {
console.log(`\n🦞 U-Claw Config Center`);
console.log(` http://127.0.0.1:${port}`);
console.log(`\n Config file: ${CONFIG_PATH}\n`);
// Persist the live port so Config.html / launchers can discover it after restarts.
try {
fs.mkdirSync(path.dirname(RUNTIME_PATH), { recursive: true });
const existing = fs.existsSync(RUNTIME_PATH) ? JSON.parse(fs.readFileSync(RUNTIME_PATH, 'utf8')) : {};
existing.configServerPort = port;
existing.configServerUpdatedAt = new Date().toISOString();
fs.writeFileSync(RUNTIME_PATH, JSON.stringify(existing, null, 2));
} catch (err) {
console.warn(` Warning: could not write ${RUNTIME_PATH}: ${err.message}`);
}
});
}
listenWithFallback(PORT_RANGE_START);