Files
LobsterAI/scripts/build-openclaw-runtime.sh
Dylan 04b8652fec refactor(openclaw): migrate runtime hotfixes to version-specific source-level git patches
Replace the code-level string-replacement hotfix system (openclawRuntimeHotfix.ts)
with git patch files applied to OpenClaw TypeScript sources before compilation.
Patches are now organized under scripts/patches/<version>/ directories, tied to
the openclaw version declared in package.json.

- Move 8 patch files into scripts/patches/v2026.3.2/
- Update apply-openclaw-patches.cjs to resolve version from package.json
- Update build-openclaw-runtime.sh patch hash to use versioned directory
- Remove openclaw:hotfix build step from all 6 platform build chains
- Delete openclawRuntimeHotfix.ts, apply-openclaw-runtime-hotfixes.cjs, and tests
- Remove applyRuntimeHotfixes() from openclawEngineManager.ts

Signed-off-by: Dylan <[email protected]>
2026-03-24 11:18:25 +08:00

316 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Build a distributable OpenClaw runtime folder for embedding into Electron.
# Usage:
# bash scripts/build-openclaw-runtime.sh [target-id]
# Example:
# OPENCLAW_SRC=/path/to/openclaw bash scripts/build-openclaw-runtime.sh mac-arm64
TARGET_ID="${1:-mac-arm64}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ELECTRON_ROOT="${ELECTRON_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
OPENCLAW_SRC="${OPENCLAW_SRC:-$ELECTRON_ROOT/../openclaw}"
OUT_DIR="${OUT_DIR:-$ELECTRON_ROOT/vendor/openclaw-runtime/$TARGET_ID}"
TARGET_PLATFORM="${TARGET_ID%%-*}"
TARGET_ARCH="${TARGET_ID#*-}"
if [[ "$TARGET_PLATFORM" == "$TARGET_ID" || -z "$TARGET_ARCH" ]]; then
echo "Invalid target id: $TARGET_ID (expected <platform>-<arch>, e.g. mac-arm64, win-x64, linux-x64)" >&2
exit 1
fi
case "$TARGET_PLATFORM" in
mac)
NPM_TARGET_PLATFORM="darwin"
;;
win)
NPM_TARGET_PLATFORM="win32"
;;
linux)
NPM_TARGET_PLATFORM="linux"
;;
*)
echo "Unsupported target platform in TARGET_ID: $TARGET_PLATFORM" >&2
exit 1
;;
esac
case "$TARGET_ARCH" in
x64|arm64|ia32)
NPM_TARGET_ARCH="$TARGET_ARCH"
;;
*)
echo "Unsupported target arch in TARGET_ID: $TARGET_ARCH" >&2
exit 1
;;
esac
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-runtime.XXXXXX")"
PACK_DIR="$WORK_DIR/pack"
EXTRACT_DIR="$WORK_DIR/extract"
mkdir -p "$PACK_DIR" "$EXTRACT_DIR"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
need_cmd node
need_cmd npm
need_cmd pnpm
need_cmd tar
if [[ ! -d "$OPENCLAW_SRC" ]]; then
echo "OPENCLAW_SRC does not exist: $OPENCLAW_SRC" >&2
exit 1
fi
node -e 'const [a,b,c]=process.versions.node.split(".").map(Number);const ok=a>22||(a===22&&(b>12||(b===12&&c>=0)));if(!ok){console.error(`Node ${process.versions.node} is too old. Require >= 22.12.0`);process.exit(1)}'
# ---------------------------------------------------------------------------
# Build cache: skip if the runtime was already built for the pinned version.
# On Windows (Git Bash / MSYS2), paths like $ELECTRON_ROOT are Unix-style
# (e.g. /d/github/LobsterAI) which Node.js cannot resolve via require().
# Use "node -" with process.argv so MSYS2 auto-converts the paths.
# ---------------------------------------------------------------------------
DESIRED_VERSION=""
DESIRED_VERSION=$(node - "$ELECTRON_ROOT" <<'READVER'
const path = require('path');
try {
const pkg = require(path.join(process.argv[2], 'package.json'));
if (pkg.openclaw && pkg.openclaw.version) console.log(pkg.openclaw.version);
} catch {}
READVER
)
# Compute a fingerprint of version-specific patch files so the build is invalidated when patches change.
PATCHES_DIR="$ELECTRON_ROOT/scripts/patches/$DESIRED_VERSION"
PATCH_HASH=""
if [[ -d "$PATCHES_DIR" ]]; then
PATCH_HASH=$(cat "$PATCHES_DIR"/*.patch 2>/dev/null | sha256sum | cut -d' ' -f1)
fi
if [[ -n "$DESIRED_VERSION" && "${OPENCLAW_FORCE_BUILD:-}" != "1" ]]; then
BUILD_INFO="$OUT_DIR/runtime-build-info.json"
if [[ -f "$BUILD_INFO" ]]; then
BUILT_VERSION=$(node - "$BUILD_INFO" <<'READBI'
try {
const info = require(process.argv[2]);
console.log(info.openclawVersion || '');
} catch {}
READBI
)
BUILT_PATCH_HASH=$(node - "$BUILD_INFO" <<'READPH'
try {
const info = require(process.argv[2]);
console.log(info.patchHash || '');
} catch {}
READPH
)
if [[ "$BUILT_VERSION" == "$DESIRED_VERSION" && "$BUILT_PATCH_HASH" == "$PATCH_HASH" ]]; then
echo "[openclaw-runtime] Already built for $DESIRED_VERSION (target=$TARGET_ID, patchHash=${PATCH_HASH:0:12}…), skipping."
echo "[openclaw-runtime] Use OPENCLAW_FORCE_BUILD=1 to force rebuild."
exit 0
fi
if [[ "$BUILT_VERSION" == "$DESIRED_VERSION" && "$BUILT_PATCH_HASH" != "$PATCH_HASH" ]]; then
echo "[openclaw-runtime] Patches changed (was=${BUILT_PATCH_HASH:0:12}…, now=${PATCH_HASH:0:12}…), rebuilding."
fi
fi
echo "[openclaw-runtime] Pinned version: $DESIRED_VERSION (current build: ${BUILT_VERSION:-none})"
fi
echo "[1/7] Building OpenClaw from source: $OPENCLAW_SRC"
pushd "$OPENCLAW_SRC" >/dev/null
corepack enable >/dev/null 2>&1 || true
pnpm install --frozen-lockfile
pnpm build
pnpm ui:build
if ! pnpm release:check; then
echo "[openclaw-runtime] release:check failed, running pnpm plugins:sync and retrying..."
echo "[openclaw-runtime] NOTE: plugins:sync may modify files in OPENCLAW_SRC ($OPENCLAW_SRC)."
pnpm plugins:sync
pnpm release:check
fi
echo "[2/7] Packing npm tarball"
npm pack --pack-destination "$PACK_DIR"
TARBALL="$(ls -1t "$PACK_DIR"/openclaw-*.tgz | head -n 1)"
if [[ -z "$TARBALL" || ! -f "$TARBALL" ]]; then
echo "Failed to locate packed tarball in $PACK_DIR" >&2
exit 1
fi
echo "[3/7] Extracting tarball"
tar -xzf "$TARBALL" -C "$EXTRACT_DIR"
PKG_DIR="$EXTRACT_DIR/package"
if [[ ! -d "$PKG_DIR" ]]; then
echo "Expected extracted package dir missing: $PKG_DIR" >&2
exit 1
fi
echo "[4/7] Preparing output runtime dir"
rm -rf "$OUT_DIR"
mkdir -p "$(dirname "$OUT_DIR")"
cp -R "$PKG_DIR" "$OUT_DIR"
# Save build metadata for traceability.
# Use `node -` so stdin is treated as script and the following args remain user args.
node - "$OUT_DIR" "$OPENCLAW_SRC" "$TARGET_ID" "$ELECTRON_ROOT" "$PATCH_HASH" <<'NODE'
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const outDir = process.argv[2];
const src = process.argv[3];
const target = process.argv[4];
const electronRoot = process.argv[5];
const patchHash = process.argv[6] || '';
// Read pinned version from package.json
let openclawVersion = '';
try {
const pkg = require(path.join(electronRoot, 'package.json'));
openclawVersion = (pkg.openclaw && pkg.openclaw.version) || '';
} catch {}
// Read git commit hash from openclaw source
let openclawCommit = '';
try {
openclawCommit = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: src,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {}
const meta = {
builtAt: new Date().toISOString(),
source: src,
target,
openclawVersion,
openclawCommit,
patchHash,
};
fs.writeFileSync(path.join(outDir, 'runtime-build-info.json'), JSON.stringify(meta, null, 2) + '\n');
NODE
echo "[5/7] Installing production dependencies"
pushd "$OUT_DIR" >/dev/null
rm -rf node_modules package-lock.json
# Avoid npm peer resolution conflicts caused by dev-only lint toolchain.
npm pkg delete devDependencies >/dev/null 2>&1 || true
echo "[openclaw-runtime] npm target platform=$NPM_TARGET_PLATFORM arch=$NPM_TARGET_ARCH"
NPM_CONFIG_LEGACY_PEER_DEPS=true \
npm_config_platform="$NPM_TARGET_PLATFORM" \
npm_config_arch="$NPM_TARGET_ARCH" \
npm install --omit=dev --no-audit --no-fund
# Runtime sanity checks before packing gateway.asar
[[ -f "openclaw.mjs" ]]
[[ -f "dist/control-ui/index.html" ]]
if [[ ! -f "dist/entry.js" && ! -f "dist/entry.mjs" ]]; then
echo "Missing dist/entry.js or dist/entry.mjs" >&2
exit 1
fi
[[ -d "node_modules" ]]
popd >/dev/null
echo "[6/7] Packing gateway entry + dist into gateway.asar"
node - "$ELECTRON_ROOT" "$OUT_DIR" <<'NODE'
const fs = require('fs');
const os = require('os');
const path = require('path');
const { createRequire } = require('module');
const electronRoot = process.argv[2];
const runtimeRoot = process.argv[3];
const requireFromElectronRoot = createRequire(path.join(electronRoot, 'package.json'));
const asar = requireFromElectronRoot('@electron/asar');
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openclaw-gateway-asar-'));
const stageRoot = path.join(stageDir, 'gateway');
const gatewayAsarPath = path.join(runtimeRoot, 'gateway.asar');
const requiredSourceEntries = ['openclaw.mjs', 'dist'];
const copyEntry = (name) => {
const src = path.join(runtimeRoot, name);
if (!fs.existsSync(src)) {
throw new Error(`Missing runtime entry before asar pack: ${src}`);
}
fs.cpSync(src, path.join(stageRoot, name), { recursive: true, force: true });
};
const listAsarEntries = () => {
const entries = new Set(asar.listPackage(gatewayAsarPath).map(e => e.replace(/\\/g, '/')));
const hasOpenClawEntry = entries.has('/openclaw.mjs');
const hasControlUiIndex = entries.has('/dist/control-ui/index.html');
const hasGatewayEntry = entries.has('/dist/entry.js') || entries.has('/dist/entry.mjs');
if (!hasOpenClawEntry || !hasControlUiIndex || !hasGatewayEntry) {
throw new Error(
`gateway.asar validation failed (openclaw.mjs=${hasOpenClawEntry}, control-ui=${hasControlUiIndex}, entry=${hasGatewayEntry}).`,
);
}
};
(async () => {
try {
fs.mkdirSync(stageRoot, { recursive: true });
for (const name of requiredSourceEntries) {
copyEntry(name);
}
fs.rmSync(gatewayAsarPath, { force: true });
await asar.createPackageWithOptions(stageRoot, gatewayAsarPath, {});
listAsarEntries();
fs.rmSync(path.join(runtimeRoot, 'openclaw.mjs'), { force: true });
// Preserve dist/control-ui/ (needed bare at runtime for gateway admin UI).
// Remove everything else in dist/ (JS modules are packed in gateway.asar).
const distDir = path.join(runtimeRoot, 'dist');
if (fs.existsSync(distDir)) {
for (const entry of fs.readdirSync(distDir)) {
if (entry === 'control-ui') continue;
fs.rmSync(path.join(distDir, entry), { recursive: true, force: true });
}
}
} finally {
fs.rmSync(stageDir, { recursive: true, force: true });
}
})().catch((error) => {
console.error(error?.stack || String(error));
process.exit(1);
});
NODE
echo "[7/7] Verifying runtime layout"
[[ -f "$OUT_DIR/gateway.asar" ]]
[[ -d "$OUT_DIR/node_modules" ]]
if [[ -f "$OUT_DIR/openclaw.mjs" ]]; then
echo "Expected openclaw.mjs to be packed into gateway.asar, but unpacked file still exists." >&2
exit 1
fi
# dist/control-ui/ is intentionally kept bare (gateway serves static files from it).
# Only fail if dist/ contains JS module files that should be in gateway.asar.
if [[ -f "$OUT_DIR/dist/entry.js" || -f "$OUT_DIR/dist/entry.mjs" ]]; then
echo "Expected dist/entry.* to be packed into gateway.asar, but unpacked files still exist." >&2
exit 1
fi
if [[ ! -f "$OUT_DIR/dist/control-ui/index.html" ]]; then
echo "dist/control-ui/index.html is missing after asar packing. The selective cleanup may have removed it." >&2
exit 1
fi
popd >/dev/null
echo "[7/7] Done"
echo "Runtime output: $OUT_DIR"