mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
feat: 版本管理 + macOS提示优化 + 部署文档更新
- OpenClaw 版本管理: 安装/升级/降级/切换版本, 汉化版/原版选择 - 新增 list_openclaw_versions API (Rust + Web) - upgrade_openclaw 支持指定版本号 - 版本选择器弹窗 (about.js) - macOS Gatekeeper 提示优化: 强调拖入应用程序, No such file 备选 - 部署文档统一使用 npm run serve 替代 npx vite - showUpgradeModal 支持自定义标题 + onClose 回调 - serve.js 路径分隔符跨平台修复 - 扩展工具页面优化 + AI助手危险工具确认
This commit is contained in:
@@ -840,18 +840,52 @@ const handlers = {
|
||||
return execSync('openclaw gateway install 2>&1', { windowsHide: true }).toString() || 'Gateway 服务已安装'
|
||||
},
|
||||
|
||||
upgrade_openclaw({ source = 'chinese' } = {}) {
|
||||
async list_openclaw_versions({ source = 'chinese' } = {}) {
|
||||
const pkg = source === 'official' ? 'openclaw' : '@qingchencloud/openclaw-zh'
|
||||
const encodedPkg = pkg.replace('/', '%2F')
|
||||
const registry = 'https://registry.npmmirror.com'
|
||||
try {
|
||||
const resp = await fetch(`${registry}/${encodedPkg}`, { headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000) })
|
||||
const data = await resp.json()
|
||||
const versions = Object.keys(data.versions || {})
|
||||
versions.sort((a, b) => {
|
||||
const pa = a.split(/[^0-9]/).filter(Boolean).map(Number)
|
||||
const pb = b.split(/[^0-9]/).filter(Boolean).map(Number)
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
if ((pb[i] || 0) !== (pa[i] || 0)) return (pb[i] || 0) - (pa[i] || 0)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return versions
|
||||
} catch (e) {
|
||||
throw new Error('查询版本失败: ' + e.message)
|
||||
}
|
||||
},
|
||||
|
||||
upgrade_openclaw({ source = 'chinese', version } = {}) {
|
||||
const OPENCLAW_DIR = path.join(homedir(), '.openclaw')
|
||||
const pkg = source === 'official' ? 'openclaw' : '@qingchencloud/openclaw-zh'
|
||||
const ver = version || 'latest'
|
||||
const npmBin = isWindows ? 'npm.cmd' : 'npm'
|
||||
try {
|
||||
const out = execSync(`${npmBin} install ${pkg}@latest --prefix "${OPENCLAW_DIR}" 2>&1`, { timeout: 120000, windowsHide: true }).toString()
|
||||
return `升级完成 (${source})\n${out.slice(-200)}`
|
||||
const out = execSync(`${npmBin} install ${pkg}@${ver} --prefix "${OPENCLAW_DIR}" 2>&1`, { timeout: 120000, windowsHide: true }).toString()
|
||||
const action = ver === 'latest' ? '升级' : '安装'
|
||||
return `${action}完成 (${pkg}@${ver})\n${out.slice(-200)}`
|
||||
} catch (e) {
|
||||
throw new Error('升级失败: ' + (e.stderr?.toString() || e.message).slice(-300))
|
||||
throw new Error('安装失败: ' + (e.stderr?.toString() || e.message).slice(-300))
|
||||
}
|
||||
},
|
||||
|
||||
uninstall_openclaw({ cleanConfig = false } = {}) {
|
||||
const npmBin = isWindows ? 'npm.cmd' : 'npm'
|
||||
try { execSync(`${npmBin} uninstall -g openclaw 2>&1`, { timeout: 60000, windowsHide: true }) } catch {}
|
||||
try { execSync(`${npmBin} uninstall -g @qingchencloud/openclaw-zh 2>&1`, { timeout: 60000, windowsHide: true }) } catch {}
|
||||
if (cleanConfig && fs.existsSync(OPENCLAW_DIR)) {
|
||||
try { fs.rmSync(OPENCLAW_DIR, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
return cleanConfig ? 'OpenClaw 已完全卸载(包括配置文件)' : 'OpenClaw 已卸载(配置文件保留)'
|
||||
},
|
||||
|
||||
uninstall_gateway() {
|
||||
if (isMac) {
|
||||
const uid = getUid()
|
||||
@@ -903,86 +937,85 @@ const handlers = {
|
||||
return true
|
||||
},
|
||||
|
||||
clawhub_trending() {
|
||||
const fallback = [
|
||||
{ slug: 'agent-browser', displayName: 'Agent Browser', summary: '浏览器自动化 CLI,支持点击、输入、抓取和截图。', author: 'TheSethRose', downloadsText: '73.9k', url: 'https://clawhub.ai/TheSethRose/agent-browser', source: 'clawhub' },
|
||||
{ slug: 'github', displayName: 'Github', summary: '通过 gh CLI 与 GitHub issues、PR、CI 交互。', author: 'steipete', downloadsText: '72.5k', url: 'https://clawhub.ai/steipete/github', source: 'clawhub' },
|
||||
{ slug: 'weather', displayName: 'Weather', summary: '获取当前天气和预报,无需 API Key。', author: 'steipete', downloadsText: '61.9k', url: 'https://clawhub.ai/steipete/weather', source: 'clawhub' },
|
||||
{ slug: 'find-skills', displayName: 'Find Skills', summary: '帮助用户发现并安装合适的 skills。', author: 'JimLiuxinghai', downloadsText: '99.3k', url: 'https://clawhub.ai/JimLiuxinghai/find-skills', source: 'clawhub' },
|
||||
{ slug: 'summarize', displayName: 'Summarize', summary: '总结网页、PDF、图片、音频等内容。', author: 'steipete', downloadsText: '82.7k', url: 'https://clawhub.ai/steipete/summarize', source: 'clawhub' },
|
||||
{ slug: 'brave-search', displayName: 'Brave Search', summary: '轻量网页搜索和内容提取。', author: 'steipete', downloadsText: '29.4k', url: 'https://clawhub.ai/steipete/brave-search', source: 'clawhub' },
|
||||
]
|
||||
// Skills 管理(模拟 openclaw skills CLI JSON 输出)
|
||||
skills_list() {
|
||||
// 尝试真实 CLI
|
||||
try {
|
||||
const out = execSync('npx -y clawhub explore --sort downloads --limit 12 --json', { encoding: 'utf8', timeout: 30000 })
|
||||
const data = JSON.parse(out)
|
||||
const items = Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : [])
|
||||
const normalized = items
|
||||
.map(item => ({
|
||||
slug: String(item?.slug || '').trim(),
|
||||
displayName: String(item?.displayName || item?.name || item?.slug || '').trim(),
|
||||
summary: String(item?.summary || item?.description || '').trim(),
|
||||
author: String(item?.author?.handle || item?.author || '').trim(),
|
||||
downloadsText: String(item?.stats?.downloadsText || item?.downloadsText || item?.downloads || '').trim(),
|
||||
url: String(item?.url || item?.canonicalUrl || '').trim(),
|
||||
source: 'clawhub'
|
||||
}))
|
||||
.filter(item => item.slug)
|
||||
return normalized.length ? normalized : fallback
|
||||
const out = execSync('npx -y openclaw skills list --json --verbose', { encoding: 'utf8', timeout: 30000 })
|
||||
return JSON.parse(out)
|
||||
} catch {
|
||||
return fallback
|
||||
// CLI 不可用时返回 mock 数据
|
||||
return {
|
||||
skills: [
|
||||
{ name: 'github', description: 'GitHub operations via gh CLI: issues, PRs, CI runs, code review.', source: 'openclaw-bundled', bundled: true, emoji: '🐙', eligible: true, disabled: false, blockedByAllowlist: false, requirements: { bins: ['gh'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [{ id: 'brew', kind: 'brew', label: 'Install GitHub CLI (brew)', bins: ['gh'] }] },
|
||||
{ name: 'weather', description: 'Get current weather and forecasts via wttr.in. No API key needed.', source: 'openclaw-bundled', bundled: true, emoji: '🌤️', eligible: true, disabled: false, blockedByAllowlist: false, requirements: { bins: ['curl'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [] },
|
||||
{ name: 'summarize', description: 'Summarize web pages, PDFs, images, audio and more.', source: 'openclaw-bundled', bundled: true, emoji: '📝', eligible: false, disabled: false, blockedByAllowlist: false, requirements: { bins: [], anyBins: [], env: [], config: [], os: [] }, missing: { bins: [], anyBins: [], env: [], config: [], os: [] }, install: [] },
|
||||
{ name: 'slack', description: 'Send and read Slack messages via CLI.', source: 'openclaw-bundled', bundled: true, emoji: '💬', eligible: false, disabled: false, blockedByAllowlist: false, requirements: { bins: ['slack-cli'], anyBins: [], env: [], config: [], os: [] }, missing: { bins: ['slack-cli'], anyBins: [], env: [], config: [], os: [] }, install: [{ id: 'brew', kind: 'brew', label: 'Install Slack CLI (brew)', bins: ['slack-cli'] }] },
|
||||
{ name: 'notion', description: 'Create and search Notion pages using the API.', source: 'openclaw-bundled', bundled: true, emoji: '📓', eligible: false, disabled: true, blockedByAllowlist: false, requirements: { bins: [], anyBins: [], env: ['NOTION_API_KEY'], config: [], os: [] }, missing: { bins: [], anyBins: [], env: ['NOTION_API_KEY'], config: [], os: [] }, install: [] },
|
||||
],
|
||||
source: 'mock',
|
||||
cliAvailable: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clawhub_search({ query }) {
|
||||
skills_info({ name }) {
|
||||
try {
|
||||
const out = execSync(`npx -y openclaw skills info ${JSON.stringify(name)} --json`, { encoding: 'utf8', timeout: 30000 })
|
||||
return JSON.parse(out)
|
||||
} catch (e) {
|
||||
throw new Error('查看详情失败: ' + (e.message || e))
|
||||
}
|
||||
},
|
||||
skills_check() {
|
||||
try {
|
||||
const out = execSync('npx -y openclaw skills check --json', { encoding: 'utf8', timeout: 30000 })
|
||||
return JSON.parse(out)
|
||||
} catch {
|
||||
return { summary: { total: 0, eligible: 0, disabled: 0, blocked: 0, missingRequirements: 0 }, eligible: [], disabled: [], blocked: [], missingRequirements: [] }
|
||||
}
|
||||
},
|
||||
skills_install_dep({ kind, spec }) {
|
||||
const cmds = {
|
||||
brew: `brew install ${spec?.formula || ''}`,
|
||||
node: `npm install -g ${spec?.package || ''}`,
|
||||
go: `go install ${spec?.module || ''}`,
|
||||
uv: `uv tool install ${spec?.package || ''}`,
|
||||
}
|
||||
const cmd = cmds[kind]
|
||||
if (!cmd) throw new Error(`不支持的安装类型: ${kind}`)
|
||||
try {
|
||||
const out = execSync(cmd, { encoding: 'utf8', timeout: 120000 })
|
||||
return { success: true, output: out.trim() }
|
||||
} catch (e) {
|
||||
throw new Error(`安装失败: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
skills_clawhub_search({ query }) {
|
||||
const q = String(query || '').trim()
|
||||
if (!q) return []
|
||||
const out = execSync(`npx -y clawhub search ${JSON.stringify(q)} --limit 12`, { encoding: 'utf8', timeout: 30000 })
|
||||
return out.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('-'))
|
||||
.map(line => {
|
||||
const parts = line.split(/\s{2,}/).filter(Boolean)
|
||||
return {
|
||||
slug: parts[0] || '',
|
||||
displayName: parts[1] || parts[0] || '',
|
||||
summary: '',
|
||||
source: 'clawhub'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
clawhub_list_installed() {
|
||||
const skillsDir = path.join(OPENCLAW_DIR, 'skills')
|
||||
if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true })
|
||||
try {
|
||||
const out = execSync('npx -y clawhub list', { cwd: homedir(), encoding: 'utf8', timeout: 30000 })
|
||||
const fromCli = out.split('\n')
|
||||
const out = execSync(`npx -y clawhub search ${JSON.stringify(q)}`, { encoding: 'utf8', timeout: 30000 })
|
||||
return out.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && line !== 'No installed skills.')
|
||||
.map(line => ({ slug: line.split(/\s+/)[0], installed: true }))
|
||||
if (fromCli.length) return fromCli
|
||||
} catch {}
|
||||
|
||||
// 兜底:直接扫描 ~/.openclaw/skills 目录,避免 CLI 输出格式变化导致空列表
|
||||
try {
|
||||
return fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory() || entry.isSymbolicLink())
|
||||
.map(entry => ({ slug: entry.name, installed: true }))
|
||||
} catch {
|
||||
return []
|
||||
.filter(line => line && !line.startsWith('-') && !line.startsWith('Search'))
|
||||
.map(line => {
|
||||
const parts = line.split(/\s{2,}/).filter(Boolean)
|
||||
return { slug: parts[0] || '', description: parts.slice(1).join(' ').trim(), source: 'clawhub' }
|
||||
})
|
||||
.filter(item => item.slug)
|
||||
} catch (e) {
|
||||
throw new Error('搜索失败: ' + (e.message || e))
|
||||
}
|
||||
},
|
||||
|
||||
clawhub_inspect({ slug }) {
|
||||
const out = execSync(`npx -y clawhub inspect ${JSON.stringify(slug)} --json`, { encoding: 'utf8', timeout: 30000 })
|
||||
return JSON.parse(out)
|
||||
},
|
||||
|
||||
clawhub_install({ slug }) {
|
||||
skills_clawhub_install({ slug }) {
|
||||
const skillsDir = path.join(OPENCLAW_DIR, 'skills')
|
||||
if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true })
|
||||
const out = execSync(`npx -y clawhub install ${JSON.stringify(slug)} --workdir .openclaw --dir skills`, { cwd: homedir(), encoding: 'utf8', timeout: 120000 })
|
||||
return { success: true, slug, output: out.trim() }
|
||||
try {
|
||||
const out = execSync(`npx -y clawhub install ${JSON.stringify(slug)}`, { cwd: homedir(), encoding: 'utf8', timeout: 120000 })
|
||||
return { success: true, slug, output: out.trim() }
|
||||
} catch (e) {
|
||||
throw new Error('安装失败: ' + (e.message || e))
|
||||
}
|
||||
},
|
||||
|
||||
// 扩展工具
|
||||
@@ -1568,6 +1601,14 @@ const handlers = {
|
||||
},
|
||||
|
||||
check_panel_update() { return { latest: null, url: 'https://github.com/qingchencloud/clawpanel/releases' } },
|
||||
|
||||
// 前端热更新
|
||||
check_frontend_update() {
|
||||
return { currentVersion: '0.6.0', latestVersion: '0.6.0', hasUpdate: false, compatible: true, updateReady: false, manifest: { version: '0.6.0', minAppVersion: '0.6.0' } }
|
||||
},
|
||||
download_frontend_update() { return { success: true, files: 12, path: path.join(OPENCLAW_DIR, 'clawpanel', 'web-update') } },
|
||||
rollback_frontend_update() { return { success: true } },
|
||||
get_update_status() { return { currentVersion: '0.6.0', updateReady: false, updateVersion: '', updateDir: path.join(OPENCLAW_DIR, 'clawpanel', 'web-update') } },
|
||||
write_env_file({ path: p, config }) {
|
||||
const expanded = p.startsWith('~/') ? path.join(homedir(), p.slice(2)) : p
|
||||
if (!expanded.startsWith(OPENCLAW_DIR)) throw new Error('只允许写入 ~/.openclaw/ 下的文件')
|
||||
@@ -1791,6 +1832,9 @@ async function _apiMiddleware(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
// 导出供 serve.js 独立部署使用
|
||||
export { _initApi, _apiMiddleware }
|
||||
|
||||
export function devApiPlugin() {
|
||||
let _inited = false
|
||||
function ensureInit() {
|
||||
|
||||
@@ -145,13 +145,14 @@ install_clawpanel() {
|
||||
cd "$INSTALL_DIR"
|
||||
npx vite build
|
||||
echo "✅ ClawPanel 安装完成: $INSTALL_DIR"
|
||||
echo "✅ 启动命令: npm run serve"
|
||||
}
|
||||
|
||||
# 创建 systemd 服务
|
||||
setup_systemd() {
|
||||
if ! command -v systemctl &> /dev/null; then
|
||||
echo "⚠️ systemd 不可用,请手动启动:"
|
||||
echo " cd $INSTALL_DIR && npx vite --port $PANEL_PORT --host 0.0.0.0"
|
||||
echo " cd $INSTALL_DIR && npm run serve -- --port $PANEL_PORT"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -168,7 +169,7 @@ After=network.target
|
||||
Type=simple
|
||||
User=$(whoami)
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
ExecStart=$(which npx) vite preview --port $PANEL_PORT --host 0.0.0.0
|
||||
ExecStart=$(which node) scripts/serve.js --port $PANEL_PORT
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=NODE_ENV=production
|
||||
@@ -189,7 +190,7 @@ After=network.target
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
ExecStart=$(which npx) vite preview --port $PANEL_PORT --host 0.0.0.0
|
||||
ExecStart=$(which node) scripts/serve.js --port $PANEL_PORT
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
205
scripts/serve.js
Normal file
205
scripts/serve.js
Normal file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ClawPanel 独立 Web 服务器(Headless 模式)
|
||||
* 无需 Tauri / Rust / GUI,纯 Node.js 运行
|
||||
* 适用于 Linux 服务器、Docker 等无桌面环境
|
||||
*
|
||||
* 用法:
|
||||
* npm run serve # 默认 0.0.0.0:1420
|
||||
* npm run serve -- --port 8080
|
||||
* npm run serve -- --host 127.0.0.1 --port 3000
|
||||
* PORT=8080 npm run serve
|
||||
*/
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { homedir } from 'os'
|
||||
import net from 'net'
|
||||
import { _initApi, _apiMiddleware } from './dev-api.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DIST_DIR = path.resolve(__dirname, '..', 'dist')
|
||||
|
||||
// === 解析命令行参数 ===
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2)
|
||||
let host = process.env.HOST || '0.0.0.0'
|
||||
let port = parseInt(process.env.PORT, 10) || 1420
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--host' && args[i + 1]) host = args[++i]
|
||||
if (args[i] === '--port' && args[i + 1]) port = parseInt(args[++i], 10)
|
||||
if (args[i] === '-p' && args[i + 1]) port = parseInt(args[++i], 10)
|
||||
if (args[i] === '--help' || args[i] === '-h') {
|
||||
console.log(`
|
||||
ClawPanel Web Server (Headless)
|
||||
|
||||
用法: node scripts/serve.js [选项]
|
||||
|
||||
选项:
|
||||
--host <addr> 监听地址 (默认: 0.0.0.0)
|
||||
--port, -p <n> 监听端口 (默认: 1420)
|
||||
--help, -h 显示帮助
|
||||
|
||||
环境变量:
|
||||
HOST 监听地址
|
||||
PORT 监听端口
|
||||
|
||||
示例:
|
||||
npm run serve # 0.0.0.0:1420
|
||||
npm run serve -- --port 8080 # 0.0.0.0:8080
|
||||
npm run serve -- --host 127.0.0.1 -p 3000
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
return { host, port }
|
||||
}
|
||||
|
||||
// === MIME 类型映射 ===
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.webp': 'image/webp',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.map': 'application/json',
|
||||
}
|
||||
|
||||
// === 静态文件服务 ===
|
||||
function serveStatic(req, res) {
|
||||
// URL 去掉 query string
|
||||
const urlPath = req.url.split('?')[0]
|
||||
let filePath = path.join(DIST_DIR, urlPath === '/' ? 'index.html' : urlPath)
|
||||
|
||||
// 安全检查:不允许目录遍历
|
||||
if (!filePath.startsWith(DIST_DIR)) {
|
||||
res.statusCode = 403
|
||||
res.end('Forbidden')
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试读取文件
|
||||
fs.stat(filePath, (err, stats) => {
|
||||
if (!err && stats.isFile()) {
|
||||
sendFile(res, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
// SPA fallback:非 API、非静态资源 → index.html
|
||||
const ext = path.extname(urlPath)
|
||||
if (!ext || ext === '.html') {
|
||||
sendFile(res, path.join(DIST_DIR, 'index.html'))
|
||||
} else {
|
||||
res.statusCode = 404
|
||||
res.end('Not Found')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function sendFile(res, filePath) {
|
||||
const ext = path.extname(filePath)
|
||||
const contentType = MIME_TYPES[ext] || 'application/octet-stream'
|
||||
|
||||
// 缓存策略:资源文件长缓存,HTML 不缓存
|
||||
if (ext === '.html') {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
} else if (filePath.includes(`${path.sep}assets${path.sep}`)) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', contentType)
|
||||
fs.createReadStream(filePath).pipe(res)
|
||||
}
|
||||
|
||||
// === 启动服务器 ===
|
||||
async function main() {
|
||||
// 检查 dist 目录
|
||||
if (!fs.existsSync(path.join(DIST_DIR, 'index.html'))) {
|
||||
console.error('❌ 未找到 dist/index.html,请先运行: npm run build')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const { host, port } = parseArgs()
|
||||
|
||||
// 初始化 API
|
||||
_initApi()
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// CORS 头(方便开发调试)
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return }
|
||||
|
||||
// API 请求
|
||||
await _apiMiddleware(req, res, () => {
|
||||
// 非 API → 静态文件
|
||||
serveStatic(req, res)
|
||||
})
|
||||
})
|
||||
|
||||
// WebSocket 代理
|
||||
let gatewayPort = 18789
|
||||
try {
|
||||
const cfgPath = path.join(homedir(), '.openclaw', 'openclaw.json')
|
||||
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'))
|
||||
gatewayPort = cfg?.gateway?.port || 18789
|
||||
} catch {}
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
if (!req.url?.startsWith('/ws')) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
const target = net.createConnection(gatewayPort, '127.0.0.1', () => {
|
||||
const reqLine = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`
|
||||
const headers = Object.entries(req.headers)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\r\n')
|
||||
target.write(reqLine + headers + '\r\n\r\n')
|
||||
if (head.length) target.write(head)
|
||||
socket.pipe(target)
|
||||
target.pipe(socket)
|
||||
})
|
||||
|
||||
target.on('error', () => socket.destroy())
|
||||
socket.on('error', () => target.destroy())
|
||||
})
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log('')
|
||||
console.log(' ┌─────────────────────────────────────────┐')
|
||||
console.log(' │ │')
|
||||
console.log(' │ 🦀 ClawPanel Web Server (Headless) │')
|
||||
console.log(' │ │')
|
||||
console.log(` │ http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/`.padEnd(44) + '│')
|
||||
if (host === '0.0.0.0') {
|
||||
console.log(` │ http://0.0.0.0:${port}/`.padEnd(44) + '│')
|
||||
}
|
||||
console.log(' │ │')
|
||||
console.log(' └─────────────────────────────────────────┘')
|
||||
console.log('')
|
||||
console.log(' 按 Ctrl+C 停止服务')
|
||||
console.log('')
|
||||
})
|
||||
|
||||
// 优雅退出
|
||||
process.on('SIGINT', () => { console.log('\n 👋 服务已停止'); process.exit(0) })
|
||||
process.on('SIGTERM', () => { console.log('\n 👋 服务已停止'); process.exit(0) })
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('启动失败:', e); process.exit(1) })
|
||||
Reference in New Issue
Block a user