Files
钟明原 4162000cca feat: initial open source release
- Electron desktop GUI for OpenClaw AI agent gateway
- Bundled Node.js runtime and OpenClaw, zero-dependency install
- Multi-agent chat, virtual team, cron scheduler
- Multi-provider model config, file/image attachments
- In-app OpenClaw update, streaming responses
- 10 UI languages, dark/light theme
- Windows (NSIS) installer, macOS support planned
- GitHub Actions CI for Windows build
- README, LICENSE (MIT), SECURITY.md, Dependabot config
2026-03-14 16:10:53 +08:00

348 lines
16 KiB
JavaScript
Raw Permalink 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.
/**
* bundle-openclaw.mjs
*
* 用 npm 将 openclaw 及其所有运行时依赖安装到临时目录,
* 再拍平为 build/openclaw/(与原 resources/openclaw/ 结构完全相同):
*
* build/openclaw/
* ├── openclaw.mjs ← 入口脚本
* ├── package.json
* └── node_modules/ ← 所有运行时依赖(平铺)
*
* 本地模式(../openclaw 目录存在):
* npm pack 打成 tarball → TMP 里 npm install tarball → 走与注册表相同的拍平流程。
* 避免 file: 依赖在 Windows 上创建符号链接,也不触发 openclaw 自身的 prepare 脚本。
*
* 注册表模式(本地源不存在):
* 直接 npm install openclaw@VERSION,拍平到 OUT。
*/
import { execSync, spawnSync } from 'child_process'
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, readdirSync, cpSync, createWriteStream, statSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import archiver from 'archiver'
const __dir = dirname(fileURLToPath(import.meta.url))
const root = join(__dir, '..')
// ── 优先使用本地 openclaw 源(../openclaw),不存在时回退到 npm 注册表 ──────────
const LOCAL_SRC = join(root, '..', 'openclaw')
const useLocal = existsSync(join(LOCAL_SRC, 'package.json'))
let VERSION = '2026.3.12'
if (useLocal) {
try {
const pkg = JSON.parse(readFileSync(join(LOCAL_SRC, 'package.json'), 'utf8'))
if (pkg.version) VERSION = pkg.version
} catch { /* 读取失败保留默认版本号 */ }
}
const REGISTRY = process.env.npm_config_registry ?? 'https://registry.npmmirror.com'
const TMP = join(root, 'build', '_openclaw_tmp')
const OUT = join(root, 'build', 'openclaw')
// ── 真实阻塞睡眠(Atomics.wait,不占用 CPU)────────────────────────────────────
function sleepSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
}
// ── Windows 下 EBUSY 重试删除(Windows Defender 扫描 node_modules 时会持续锁文件)
// 使用 spawnSync 直接传参调用 cmd.exe,避免 shell 转义问题;
// 每次重试间隔 30s,最多等 ~5 分钟,通常 Defender 会在此之前完成扫描。
function rmSyncWithRetry(p, maxRetries = 10, delayMs = 30_000) {
for (let i = 0; i <= maxRetries; i++) {
if (!existsSync(p)) return
try {
if (process.platform === 'win32') {
spawnSync('cmd', ['/c', 'rmdir', '/s', '/q', p.replace(/\//g, '\\')], { stdio: 'ignore' })
} else {
rmSync(p, { recursive: true, force: true })
return
}
} catch { /* ignore errors, check existence below */ }
if (!existsSync(p)) return
if (i < maxRetries) {
const waitSec = Math.round(delayMs / 1000)
console.log(`[bundle-openclaw] ⏳ 目录被占用(Defender 扫描中),等待 ${waitSec}s 后重试 (${i + 1}/${maxRetries})...`)
sleepSync(delayMs)
}
}
// 超时后最后一次尝试 Node rmSync(会抛错,让外层感知)
if (existsSync(p)) rmSync(p, { recursive: true, force: true })
}
// ── 清理旧产物 ─────────────────────────────────────────────────────────────────
if (existsSync(TMP)) rmSyncWithRetry(TMP)
if (existsSync(OUT)) rmSyncWithRetry(OUT)
mkdirSync(TMP, { recursive: true })
// ── libsignal-node stub(WhatsApp 插件的 git 依赖,国内无法访问 GitHub)─────────
const libsignalStubDir = join(TMP, '_stubs', 'libsignal-node')
mkdirSync(libsignalStubDir, { recursive: true })
writeFileSync(
join(libsignalStubDir, 'package.json'),
JSON.stringify({ name: 'libsignal-node', version: '5.0.0', main: 'index.js' })
)
writeFileSync(join(libsignalStubDir, 'index.js'), 'module.exports = {};\n')
const stubPath = libsignalStubDir.replace(/\\/g, '/')
writeFileSync(
join(TMP, 'package.json'),
JSON.stringify({
name: '_openclaw_bundle',
version: '1.0.0',
private: true,
overrides: { 'libsignal-node': `file:${stubPath}` },
})
)
// ── 安装 openclaw ──────────────────────────────────────────────────────────────
if (useLocal) {
// 用 npm pack 把本地源打成 tarball(只含 package.json files 声明的文件,干净分发包)
console.log(`[bundle-openclaw] 使用本地源 ${LOCAL_SRC} (v${VERSION})`)
console.log('[bundle-openclaw] 打包本地源为 tarball(npm pack --ignore-scripts)...')
// Windows 下 npm pack 会强制执行 prepare 生命周期(--ignore-scripts 无法阻止),
// openclaw 的 prepare 脚本使用 bash 语法(/dev/null),在 cmd.exe 下会报
// "系统找不到指定的路径"(无害,|| exit 0 保证退出码为 0)。
// 若系统有 Git Bash,设置 script-shell 可消除该警告。
const gitBash = process.platform === 'win32'
? (() => {
try {
const gitExe = execSync('where git', { encoding: 'utf8' }).trim().split('\n')[0].trim()
// C:\Program Files\Git\cmd\git.exe → C:\Program Files\Git\bin\bash.exe
const bash = gitExe.replace(/\\cmd\\git\.exe$/i, '\\bin\\bash.exe')
if (bash !== gitExe && existsSync(bash)) return bash
} catch { /* git not found */ }
return null
})()
: null
const packEnv = gitBash
? { ...process.env, npm_config_script_shell: gitBash }
: process.env
execSync(
`npm pack --pack-destination ${TMP} --ignore-scripts`,
{ cwd: LOCAL_SRC, stdio: 'inherit', env: packEnv }
)
const tarball = readdirSync(TMP).find((f) => f.endsWith('.tgz'))
if (!tarball) throw new Error('[bundle-openclaw] npm pack 失败:未找到 .tgz 文件')
console.log(`[bundle-openclaw] 安装 tarball: ${tarball}`)
execSync(
`npm install ${join(TMP, tarball).replace(/\\/g, '/')} --registry ${REGISTRY} --no-audit --no-fund --ignore-scripts`,
{ cwd: TMP, stdio: 'inherit' }
)
} else {
console.log(`[bundle-openclaw] 正在从 npm 安装 openclaw@${VERSION}...`)
execSync(
`npm install openclaw@${VERSION} --registry ${REGISTRY} --no-audit --no-fund`,
{ cwd: TMP, stdio: 'inherit' }
)
}
// ── 把 openclaw 源码拷到 OUT 根(含自身 node_modules 若有)────────────────────
console.log('[bundle-openclaw] 拼装目录结构...')
const openclawSrc = join(TMP, 'node_modules', 'openclaw')
cpSync(openclawSrc, OUT, { recursive: true, dereference: true })
// ── 本地模式:确保 dist/ 有内容(npm pack 打包时若源码 dist/ 未被正确纳入则补充)──
if (useLocal) {
const outDist = join(OUT, 'dist')
const srcDist = join(LOCAL_SRC, 'dist')
const distEmpty = !existsSync(outDist) || readdirSync(outDist).length === 0
if (distEmpty && existsSync(srcDist) && readdirSync(srcDist).length > 0) {
console.log('[bundle-openclaw] ⚠ dist/ 为空,从本地源直接补充...')
mkdirSync(outDist, { recursive: true })
cpSync(srcDist, outDist, { recursive: true, dereference: true })
console.log('[bundle-openclaw] ✓ dist/ 补充完成')
}
}
// ── 把 openclaw 的依赖(TMP/node_modules/*)拷到 OUT/node_modules/ ────────────
const outMods = join(OUT, 'node_modules')
mkdirSync(outMods, { recursive: true })
for (const pkg of readdirSync(join(TMP, 'node_modules'))) {
if (pkg === 'openclaw') continue
if (pkg === '.package-lock.json') continue
const dest = join(outMods, pkg)
if (existsSync(dest)) continue
cpSync(join(TMP, 'node_modules', pkg), dest, { recursive: true, dereference: true })
}
// ── 本地模式:用本地源自身的 @mariozechner/* 覆盖 npm 安装的版本 ─────────────────
// 背景:openclaw dist 编译时使用的是 node_modules/ 里的实际版本,
// 但 package.json 里写的版本可能不同步(如 @mariozechner/pi-ai 0.55.3 vs 0.57.1)。
if (useLocal) {
const localModzScope = join(LOCAL_SRC, 'node_modules', '@mariozechner')
if (existsSync(localModzScope)) {
const scopeOut = join(outMods, '@mariozechner')
mkdirSync(scopeOut, { recursive: true })
for (const pkg of readdirSync(localModzScope)) {
const src = join(localModzScope, pkg)
const dest = join(scopeOut, pkg)
if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
cpSync(src, dest, { recursive: true, dereference: true })
const ver = JSON.parse(readFileSync(join(src, 'package.json'), 'utf8')).version
console.log(`[bundle-openclaw] ✓ 覆盖 @mariozechner/${pkg}@${ver}(来自本地源)`)
}
}
}
// ── 清理临时目录 ───────────────────────────────────────────────────────────────
rmSyncWithRetry(TMP)
// ── 删除运行时不需要的大型包(减小安装包体积)────────────────────────────────────
const UNUSED_LARGE_PKGS = [
'koffi', // ~87 MB — native binding,本应用不用
'pdfjs-dist', // ~40 MB — PDF 渲染,本应用不用
'node-llama-cpp', // ~32 MB — 本地 LLM,本应用不用
'@node-llama-cpp', // ~ ? — 同上(作用域包)
'playwright-core', // ~10 MB — 浏览器自动化,本应用不用
'@playwright', // ~ ? — 同上(作用域包)
'typescript', // ~ ? — 编译器,运行时不需要
'@cloudflare', // ~ ? — Cloudflare SDK
]
for (const pkg of UNUSED_LARGE_PKGS) {
const p = join(outMods, pkg)
if (existsSync(p)) {
rmSync(p, { recursive: true, force: true })
console.log(`[bundle-openclaw] 🗑 已删除 ${pkg}`)
}
}
// ── 写入 Windows 黑窗口修复包装脚本 ───────────────────────────────────────────
writeFileSync(join(OUT, 'easiest-claw-gateway.mjs'), `/**
* easiest-claw-gateway.mjs — EasiestClaw 包装入口
* 在 Windows 上 patch child_process,然后启动真正的 OpenClaw Gateway。
*/
import { createRequire } from 'module'
import { fileURLToPath } from 'url'
import { join, dirname } from 'path'
process.env.pm_exec_path = join(dirname(fileURLToPath(import.meta.url)), 'dist', 'entry.js')
if (process.platform === 'win32') {
const require = createRequire(import.meta.url)
const cp = require('child_process')
const _spawn = cp.spawn
cp.spawn = function (cmd, args, opts) {
if (!Array.isArray(args)) { opts = args; args = [] }
return _spawn.call(this, cmd, args, Object.assign({ windowsHide: true }, opts || {}))
}
const _spawnSync = cp.spawnSync
cp.spawnSync = function (cmd, args, opts) {
if (!Array.isArray(args)) { opts = args; args = [] }
return _spawnSync.call(this, cmd, args, Object.assign({ windowsHide: true }, opts || {}))
}
const _execFile = cp.execFile
cp.execFile = function (file, args, opts, cb) {
if (typeof args === 'function') { cb = args; args = []; opts = {} }
else if (!Array.isArray(args)) { cb = opts; opts = typeof args === 'object' ? args : {}; args = [] }
else if (typeof opts === 'function') { cb = opts; opts = {} }
return _execFile.call(this, file, args, Object.assign({ windowsHide: true }, opts || {}), cb)
}
const _exec = cp.exec
cp.exec = function (cmd, opts, cb) {
if (typeof opts === 'function') { cb = opts; opts = {} }
return _exec.call(this, cmd, Object.assign({ windowsHide: true }, opts || {}), cb)
}
const _execSync = cp.execSync
cp.execSync = function (cmd, opts) {
return _execSync.call(this, cmd, Object.assign({ windowsHide: true }, opts || {}))
}
}
await import('./openclaw.mjs')
`)
console.log('[bundle-openclaw] ✓ easiest-claw-gateway.mjs 已写入')
// ── 打包为 3 个 ZIP 并行压缩(core + mods-a + mods-b)────────────────────────
// core: dist/ + 根文件(不含 node_modules)
// mods-a/b: node_modules 按包名字母序均分为两半
// 三个 zip 同时写入,利用多核 IO;解压时也可并行
const nmEntries = readdirSync(join(OUT, 'node_modules')).filter(n => n !== '.package-lock.json')
const mid = Math.ceil(nmEntries.length / 2)
const nmA = nmEntries.slice(0, mid)
const nmB = nmEntries.slice(mid)
function createZipAsync(zipPath, builder) {
rmSync(zipPath, { force: true })
return new Promise((resolve, reject) => {
const output = createWriteStream(zipPath)
const archive = archiver('zip', { zlib: { level: 6 } })
output.on('close', () => resolve(archive.pointer()))
archive.on('warning', (err) => { if (err.code !== 'ENOENT') reject(err) })
archive.on('error', reject)
archive.pipe(output)
builder(archive)
archive.finalize()
})
}
const CORE_ZIP = join(root, 'build', 'openclaw-core.zip')
const MODS_A_ZIP = join(root, 'build', 'openclaw-mods-a.zip')
const MODS_B_ZIP = join(root, 'build', 'openclaw-mods-b.zip')
console.log(`[bundle-openclaw] 并行压缩 3 个 zip(core + mods×2, level=6)...`)
console.log(`[bundle-openclaw] core : dist/ + 根文件`)
console.log(`[bundle-openclaw] mods-a: node_modules [0..${mid - 1}] (${nmA.length} 包)`)
console.log(`[bundle-openclaw] mods-b: node_modules [${mid}..] (${nmB.length} 包)`)
const [sizeCore, sizeA, sizeB] = await Promise.all([
// ── core:所有非 node_modules 的顶层条目 ───────────────────────────────────
createZipAsync(CORE_ZIP, (archive) => {
for (const name of readdirSync(OUT)) {
if (name === 'node_modules') continue
const p = join(OUT, name)
if (statSync(p).isDirectory()) {
archive.directory(p, `openclaw/${name}`)
} else {
archive.file(p, { name: `openclaw/${name}` })
}
}
}),
// ── mods-a:前半 node_modules ─────────────────────────────────────────────
createZipAsync(MODS_A_ZIP, (archive) => {
for (const pkg of nmA) {
const p = join(OUT, 'node_modules', pkg)
if (!existsSync(p)) continue
if (statSync(p).isDirectory()) {
archive.directory(p, `openclaw/node_modules/${pkg}`)
} else {
archive.file(p, { name: `openclaw/node_modules/${pkg}` })
}
}
}),
// ── mods-b:后半 node_modules ─────────────────────────────────────────────
createZipAsync(MODS_B_ZIP, (archive) => {
for (const pkg of nmB) {
const p = join(OUT, 'node_modules', pkg)
if (!existsSync(p)) continue
if (statSync(p).isDirectory()) {
archive.directory(p, `openclaw/node_modules/${pkg}`)
} else {
archive.file(p, { name: `openclaw/node_modules/${pkg}` })
}
}
}),
])
// ── 写入 openclaw 版本文件(供 openclaw-init.ts 解压标记使用)────────────────────
// 用 openclaw 版本而非 app 版本做标记,这样更新 Electron Shell 但 openclaw 未变时
// 不会触发重复解压
writeFileSync(join(root, 'build', 'openclaw.version'), VERSION)
console.log(`[bundle-openclaw] ✓ openclaw.version = ${VERSION}`)
const fmtMB = (b) => (b / 1024 / 1024).toFixed(1)
console.log(`[bundle-openclaw] ✓ openclaw-core.zip (${fmtMB(sizeCore)} MB)`)
console.log(`[bundle-openclaw] ✓ openclaw-mods-a.zip (${fmtMB(sizeA)} MB)`)
console.log(`[bundle-openclaw] ✓ openclaw-mods-b.zip (${fmtMB(sizeB)} MB)`)
console.log(`[bundle-openclaw] 完成 → ${OUT}`)