feat: prepare Zhinian desktop pilot
This commit is contained in:
@@ -199,6 +199,46 @@ function cleanupNativePlatformPackages(nodeModulesDir, platform, arch) {
|
||||
return removed;
|
||||
}
|
||||
|
||||
function removeOptionalNativeClipboard(nodeModulesDir) {
|
||||
const scopeDir = join(nodeModulesDir, '@mariozechner');
|
||||
if (!existsSync(scopeDir)) return 0;
|
||||
|
||||
let removed = 0;
|
||||
for (const entry of readdirSync(scopeDir)) {
|
||||
if (entry === 'clipboard' || entry.startsWith('clipboard-')) {
|
||||
try {
|
||||
rmSync(join(scopeDir, entry), { recursive: true, force: true });
|
||||
removed++;
|
||||
} catch { /* */ }
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function copyNianxxPlayNodeModules(resourcesDir, platform, arch) {
|
||||
const src = join(__dirname, '..', 'build', 'apps', 'nianxx-play', 'node_modules');
|
||||
const nianxxPlayRoot = join(resourcesDir, 'resources', 'nianxx-play');
|
||||
const dest = join(nianxxPlayRoot, 'node_modules');
|
||||
|
||||
if (!existsSync(nianxxPlayRoot)) return;
|
||||
if (!existsSync(src)) {
|
||||
console.warn('[after-pack] ⚠️ build/apps/nianxx-play/node_modules not found. Run prepare:nianxx-play first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const depCount = readdirSync(src, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && d.name !== '.bin')
|
||||
.length;
|
||||
|
||||
console.log(`[after-pack] Copying ${depCount} NianxxPlay dependencies to ${dest} ...`);
|
||||
rmSync(dest, { recursive: true, force: true });
|
||||
cpSync(src, dest, { recursive: true, dereference: true });
|
||||
cleanupUnnecessaryFiles(dest);
|
||||
cleanupKoffi(dest, platform, arch);
|
||||
cleanupNativePlatformPackages(dest, platform, arch);
|
||||
console.log('[after-pack] ✅ NianxxPlay node_modules copied.');
|
||||
}
|
||||
|
||||
// ── Broken module patcher ─────────────────────────────────────────────────────
|
||||
// Some bundled packages have transpiled CJS that sets `module.exports = exports.default`
|
||||
// without ever assigning `exports.default`, leaving module.exports === undefined.
|
||||
@@ -371,13 +411,10 @@ function patchBrokenModules(nodeModulesDir) {
|
||||
}
|
||||
|
||||
// ── Plugin ID mismatch patcher ───────────────────────────────────────────────
|
||||
// Some plugins (e.g. wecom) have a compiled JS entry that hardcodes a different
|
||||
// ID than what openclaw.plugin.json declares. The Gateway rejects mismatches,
|
||||
// so we fix them after copying.
|
||||
// Some plugins have a compiled JS entry that hardcodes a different ID than what
|
||||
// openclaw.plugin.json declares. Keep this hook for future bundled plugins.
|
||||
|
||||
const PLUGIN_ID_FIXES = {
|
||||
'wecom-openclaw-plugin': 'wecom',
|
||||
};
|
||||
const PLUGIN_ID_FIXES = {};
|
||||
|
||||
function patchPluginIds(pluginDir, expectedId) {
|
||||
const { readFileSync, writeFileSync } = require('fs');
|
||||
@@ -563,6 +600,15 @@ exports.default = async function afterPack(context) {
|
||||
cpSync(src, dest, { recursive: true });
|
||||
console.log('[after-pack] ✅ openclaw node_modules copied.');
|
||||
|
||||
const clipboardRemoved = removeOptionalNativeClipboard(dest);
|
||||
if (clipboardRemoved > 0) {
|
||||
console.log(`[after-pack] ✅ Removed optional native clipboard packages (${clipboardRemoved}) to avoid macOS Gatekeeper prompts.`);
|
||||
}
|
||||
|
||||
// 1.0 Copy bundled large-app runtime deps that electron-builder skips because
|
||||
// node_modules/ is ignored globally.
|
||||
copyNianxxPlayNodeModules(resourcesDir, platform, arch);
|
||||
|
||||
// Patch broken modules whose CJS transpiled output sets module.exports = undefined,
|
||||
// causing TypeError in Node.js 22+ ESM interop.
|
||||
patchBrokenModules(dest);
|
||||
@@ -573,9 +619,6 @@ exports.default = async function afterPack(context) {
|
||||
// directory doesn't exist (build/openclaw-plugins/ may not be pre-generated)
|
||||
// - node_modules/ is excluded by .gitignore so the deps copy must be manual
|
||||
const BUNDLED_PLUGINS = [
|
||||
{ npmName: '@soimy/dingtalk', pluginId: 'dingtalk' },
|
||||
{ npmName: '@wecom/wecom-openclaw-plugin', pluginId: 'wecom' },
|
||||
{ npmName: '@larksuite/openclaw-lark', pluginId: 'feishu-openclaw-plugin' },
|
||||
{ npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' },
|
||||
];
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
*
|
||||
* Build a self-contained mirror of OpenClaw third-party plugins for packaging.
|
||||
* Current plugins:
|
||||
* - @soimy/dingtalk -> build/openclaw-plugins/dingtalk
|
||||
* - @wecom/wecom-openclaw-plugin -> build/openclaw-plugins/wecom
|
||||
* - @tencent-weixin/openclaw-weixin -> build/openclaw-plugins/openclaw-weixin
|
||||
*
|
||||
* The output plugin directory contains:
|
||||
@@ -37,9 +35,6 @@ function normWin(p) {
|
||||
}
|
||||
|
||||
const PLUGINS = [
|
||||
{ npmName: '@soimy/dingtalk', pluginId: 'dingtalk' },
|
||||
{ npmName: '@wecom/wecom-openclaw-plugin', pluginId: 'wecom' },
|
||||
{ npmName: '@larksuite/openclaw-lark', pluginId: 'feishu-openclaw-plugin' },
|
||||
{ npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' },
|
||||
];
|
||||
|
||||
@@ -183,8 +178,7 @@ function bundleOnePlugin({ npmName, pluginId }) {
|
||||
|
||||
/**
|
||||
* Patch plugin entry JS files so the exported `id` matches openclaw.plugin.json.
|
||||
* Some plugins (e.g. wecom) ship with a hardcoded ID in their compiled output
|
||||
* that differs from the manifest, causing a Gateway "plugin id mismatch" error.
|
||||
* Kept as a safety hook for future bundled plugins that may ship mismatched IDs.
|
||||
*/
|
||||
function patchPluginId(pluginDir, expectedId) {
|
||||
const manifestPath = path.join(pluginDir, 'openclaw.plugin.json');
|
||||
@@ -206,9 +200,7 @@ function patchPluginId(pluginDir, expectedId) {
|
||||
|
||||
// Known ID mismatches to patch. Keys are the wrong ID found in compiled JS,
|
||||
// values are the correct ID (must match openclaw.plugin.json).
|
||||
const ID_FIXES = {
|
||||
'wecom-openclaw-plugin': 'wecom',
|
||||
};
|
||||
const ID_FIXES = {};
|
||||
|
||||
for (const entry of entryFiles) {
|
||||
const entryPath = path.join(pluginDir, entry);
|
||||
@@ -219,7 +211,6 @@ function patchPluginId(pluginDir, expectedId) {
|
||||
|
||||
for (const [wrongId, correctId] of Object.entries(ID_FIXES)) {
|
||||
if (correctId !== expectedId) continue;
|
||||
// Replace id: "wecom-openclaw-plugin" or id: 'wecom-openclaw-plugin'
|
||||
const pattern = new RegExp(`(\\bid\\s*:\\s*)(["'])${wrongId.replace(/-/g, '\\-')}\\2`, 'g');
|
||||
const replaced = content.replace(pattern, `$1$2${correctId}$2`);
|
||||
if (replaced !== content) {
|
||||
|
||||
@@ -21,6 +21,16 @@ import 'zx/globals';
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const OUTPUT = path.join(ROOT, 'build', 'openclaw');
|
||||
const NODE_MODULES = path.join(ROOT, 'node_modules');
|
||||
const YINIAN_RUNTIME_PATCH_MARKER_FILE = '.yinian-runtime-patch.json';
|
||||
const YINIAN_RUNTIME_PATCH_MARKER = {
|
||||
id: 'yinian-openclaw-runtime',
|
||||
version: '2026-05-07-desktop-fast-chat-v1',
|
||||
patches: [
|
||||
'bundled-npm-runner-env',
|
||||
'optional-native-clipboard-removed',
|
||||
'desktop-fast-chat-lightweight-tools',
|
||||
],
|
||||
};
|
||||
|
||||
// On Windows, pnpm virtual store paths can exceed MAX_PATH (260 chars).
|
||||
function normWin(p) {
|
||||
@@ -142,6 +152,12 @@ const SKIP_PACKAGES = new Set([
|
||||
]);
|
||||
const SKIP_SCOPES = ['@cloudflare/', '@types/'];
|
||||
let skippedDevCount = 0;
|
||||
let skippedOptionalNativeCount = 0;
|
||||
|
||||
function isOptionalNativeClipboardPackage(name) {
|
||||
return name === '@mariozechner/clipboard'
|
||||
|| name.startsWith('@mariozechner/clipboard-');
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { nodeModulesDir, skipPkg } = queue.shift();
|
||||
@@ -151,6 +167,11 @@ while (queue.length > 0) {
|
||||
// Skip the package that owns this virtual store entry (it's the package itself, not a dep)
|
||||
if (name === skipPkg) continue;
|
||||
|
||||
if (isOptionalNativeClipboardPackage(name)) {
|
||||
skippedOptionalNativeCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SKIP_PACKAGES.has(name) || SKIP_SCOPES.some(s => name.startsWith(s))) {
|
||||
skippedDevCount++;
|
||||
continue;
|
||||
@@ -178,6 +199,9 @@ while (queue.length > 0) {
|
||||
|
||||
echo` Found ${collected.size} total packages (direct + transitive)`;
|
||||
echo` Skipped ${skippedDevCount} dev-only package references`;
|
||||
if (skippedOptionalNativeCount > 0) {
|
||||
echo` Skipped ${skippedOptionalNativeCount} optional native clipboard package references`;
|
||||
}
|
||||
|
||||
// 4b. Collect extra packages required by ClawX's Electron main process that are
|
||||
// NOT deps of openclaw. These are resolved from openclaw's context at runtime
|
||||
@@ -186,8 +210,113 @@ echo` Skipped ${skippedDevCount} dev-only package references`;
|
||||
//
|
||||
// For each package we resolve it from the workspace's own node_modules,
|
||||
// then BFS its transitive deps exactly like we did for openclaw above.
|
||||
const OPENCLAW_RUNTIME_DEPS_PACKAGES = [
|
||||
'@agentclientprotocol/claude-agent-acp',
|
||||
'@agentclientprotocol/sdk',
|
||||
'@anthropic-ai/sdk',
|
||||
'@anthropic-ai/vertex-sdk',
|
||||
'@aws-sdk/client-bedrock',
|
||||
'@aws-sdk/client-bedrock-runtime',
|
||||
'@aws-sdk/client-s3',
|
||||
'@aws-sdk/credential-provider-node',
|
||||
'@aws-sdk/s3-request-presigner',
|
||||
'@aws/bedrock-token-generator',
|
||||
'@azure/identity',
|
||||
'@clack/prompts',
|
||||
'@clawdbot/lobster',
|
||||
'@discordjs/voice',
|
||||
'@google/genai',
|
||||
'@grammyjs/runner',
|
||||
'@grammyjs/transformer-throttler',
|
||||
'@homebridge/ciao',
|
||||
'@lancedb/lancedb',
|
||||
'@larksuiteoapi/node-sdk',
|
||||
'@line/bot-sdk',
|
||||
'@lydell/node-pty',
|
||||
'@mariozechner/pi-agent-core',
|
||||
'@mariozechner/pi-ai',
|
||||
'@mariozechner/pi-coding-agent',
|
||||
'@matrix-org/matrix-sdk-crypto-nodejs',
|
||||
'@matrix-org/matrix-sdk-crypto-wasm',
|
||||
'@microsoft/teams.api',
|
||||
'@microsoft/teams.apps',
|
||||
'@modelcontextprotocol/sdk',
|
||||
'@mozilla/readability',
|
||||
'@openai/codex',
|
||||
'@opentelemetry/api',
|
||||
'@opentelemetry/api-logs',
|
||||
'@opentelemetry/exporter-logs-otlp-proto',
|
||||
'@opentelemetry/exporter-metrics-otlp-proto',
|
||||
'@opentelemetry/exporter-trace-otlp-proto',
|
||||
'@opentelemetry/resources',
|
||||
'@opentelemetry/sdk-logs',
|
||||
'@opentelemetry/sdk-metrics',
|
||||
'@opentelemetry/sdk-node',
|
||||
'@opentelemetry/sdk-trace-base',
|
||||
'@opentelemetry/semantic-conventions',
|
||||
'@pierre/diffs',
|
||||
'@pierre/theme',
|
||||
'@slack/bolt',
|
||||
'@slack/web-api',
|
||||
'@tencent-connect/qqbot-connector',
|
||||
'@tloncorp/tlon-skill',
|
||||
'@twurple/api',
|
||||
'@twurple/auth',
|
||||
'@twurple/chat',
|
||||
'@urbit/aura',
|
||||
'@whiskeysockets/baileys',
|
||||
'@zed-industries/codex-acp',
|
||||
'acpx',
|
||||
'ajv',
|
||||
'chokidar',
|
||||
'commander',
|
||||
'croner',
|
||||
'discord-api-types',
|
||||
'dotenv',
|
||||
'express',
|
||||
'fake-indexeddb',
|
||||
'gaxios',
|
||||
'global-agent',
|
||||
'google-auth-library',
|
||||
'grammy',
|
||||
'https-proxy-agent',
|
||||
'jiti',
|
||||
'jimp',
|
||||
'json5',
|
||||
'jsonwebtoken',
|
||||
'jszip',
|
||||
'jwks-rsa',
|
||||
'linkedom',
|
||||
'markdown-it',
|
||||
'matrix-js-sdk',
|
||||
'minimatch',
|
||||
'mpg123-decoder',
|
||||
'music-metadata',
|
||||
'node-edge-tts',
|
||||
'nostr-tools',
|
||||
'openai',
|
||||
'openshell',
|
||||
'opusscript',
|
||||
'pdfjs-dist',
|
||||
'playwright-core',
|
||||
'semver',
|
||||
'sharp',
|
||||
'silk-wasm',
|
||||
'sqlite-vec',
|
||||
'tar',
|
||||
'tokenjuice',
|
||||
'tslog',
|
||||
'typebox',
|
||||
'undici',
|
||||
'web-push',
|
||||
'ws',
|
||||
'yaml',
|
||||
'zca-js',
|
||||
'zod',
|
||||
];
|
||||
|
||||
const EXTRA_BUNDLED_PACKAGES = [
|
||||
'@whiskeysockets/baileys', // WhatsApp channel (was a dep of old clawdbot, not openclaw)
|
||||
...OPENCLAW_RUNTIME_DEPS_PACKAGES,
|
||||
'qrcode-terminal', // QR rendering is loaded from OpenClaw context by channel login flows
|
||||
];
|
||||
|
||||
@@ -215,6 +344,10 @@ for (const pkgName of EXTRA_BUNDLED_PACKAGES) {
|
||||
const packages = listPackages(nodeModulesDir);
|
||||
for (const { name, fullPath } of packages) {
|
||||
if (name === skipPkg) continue;
|
||||
if (isOptionalNativeClipboardPackage(name)) {
|
||||
skippedOptionalNativeCount++;
|
||||
continue;
|
||||
}
|
||||
if (SKIP_PACKAGES.has(name) || SKIP_SCOPES.some(s => name.startsWith(s))) continue;
|
||||
let realPath;
|
||||
try { realPath = fs.realpathSync(fullPath); } catch { continue; }
|
||||
@@ -250,6 +383,11 @@ let copiedCount = 0;
|
||||
let skippedDupes = 0;
|
||||
|
||||
for (const [realPath, pkgName] of collected) {
|
||||
if (isOptionalNativeClipboardPackage(pkgName)) {
|
||||
skippedOptionalNativeCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (copiedNames.has(pkgName)) {
|
||||
skippedDupes++;
|
||||
continue; // Keep the first version (closer to openclaw in dep tree)
|
||||
@@ -299,6 +437,10 @@ if (fs.existsSync(extensionsDir)) {
|
||||
for (const scopeEntry of scopeEntries) {
|
||||
if (!scopeEntry.isDirectory()) continue;
|
||||
const scopedName = `${pkgEntry.name}/${scopeEntry.name}`;
|
||||
if (isOptionalNativeClipboardPackage(scopedName)) {
|
||||
skippedOptionalNativeCount++;
|
||||
continue;
|
||||
}
|
||||
if (copiedNames.has(scopedName)) continue;
|
||||
const srcScoped = path.join(srcPkg, scopeEntry.name);
|
||||
const destScoped = path.join(outputNodeModules, pkgEntry.name, scopeEntry.name);
|
||||
@@ -310,6 +452,10 @@ if (fs.existsSync(extensionsDir)) {
|
||||
} catch { /* skip on copy error */ }
|
||||
}
|
||||
} else {
|
||||
if (isOptionalNativeClipboardPackage(pkgEntry.name)) {
|
||||
skippedOptionalNativeCount++;
|
||||
continue;
|
||||
}
|
||||
if (copiedNames.has(pkgEntry.name)) continue;
|
||||
const destPkg = path.join(outputNodeModules, pkgEntry.name);
|
||||
try {
|
||||
@@ -326,6 +472,31 @@ if (mergedExtCount > 0) {
|
||||
echo` Merged ${mergedExtCount} extension packages into top-level node_modules`;
|
||||
}
|
||||
|
||||
let forcedRuntimeDepCount = 0;
|
||||
for (const pkgName of OPENCLAW_RUNTIME_DEPS_PACKAGES) {
|
||||
if (isOptionalNativeClipboardPackage(pkgName)) continue;
|
||||
const pkgLink = path.join(NODE_MODULES, ...pkgName.split('/'));
|
||||
if (!fs.existsSync(pkgLink)) continue;
|
||||
|
||||
let pkgReal;
|
||||
try { pkgReal = fs.realpathSync(pkgLink); } catch { continue; }
|
||||
|
||||
const dest = path.join(outputNodeModules, pkgName);
|
||||
try {
|
||||
fs.rmSync(normWin(dest), { recursive: true, force: true });
|
||||
fs.mkdirSync(normWin(path.dirname(dest)), { recursive: true });
|
||||
fs.cpSync(normWin(pkgReal), normWin(dest), { recursive: true, dereference: true });
|
||||
copiedNames.add(pkgName);
|
||||
forcedRuntimeDepCount++;
|
||||
} catch (err) {
|
||||
echo` ⚠️ Failed to force-copy runtime dependency ${pkgName}: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (forcedRuntimeDepCount > 0) {
|
||||
echo` Forced ${forcedRuntimeDepCount} runtime dependency package(s) to workspace-resolved versions`;
|
||||
}
|
||||
|
||||
// 6. Clean up the bundle to reduce package size
|
||||
//
|
||||
// This removes platform-agnostic waste: dev artifacts, docs, source maps,
|
||||
@@ -480,12 +651,38 @@ function cleanupBundle(outputDir) {
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
function removeOptionalNativeClipboard(nodeModulesDir) {
|
||||
const scopeDir = path.join(nodeModulesDir, '@mariozechner');
|
||||
let removedCount = 0;
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(scopeDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const packageName = `@mariozechner/${entry.name}`;
|
||||
if (!isOptionalNativeClipboardPackage(packageName)) continue;
|
||||
if (rmSafe(path.join(scopeDir, entry.name))) {
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
echo``;
|
||||
echo`🧹 Cleaning up bundle (removing dev artifacts, docs, source maps, type defs)...`;
|
||||
const sizeBefore = getDirSize(OUTPUT);
|
||||
const cleanedCount = cleanupBundle(OUTPUT);
|
||||
const removedClipboardCount = removeOptionalNativeClipboard(outputNodeModules);
|
||||
const sizeAfter = getDirSize(OUTPUT);
|
||||
echo` Removed ${cleanedCount} files/directories`;
|
||||
if (removedClipboardCount > 0) {
|
||||
echo` Removed ${removedClipboardCount} optional native clipboard package(s)`;
|
||||
}
|
||||
echo` Size: ${formatSize(sizeBefore)} → ${formatSize(sizeAfter)} (saved ${formatSize(sizeBefore - sizeAfter)})`;
|
||||
|
||||
// 7. Patch known broken packages
|
||||
@@ -826,11 +1023,142 @@ function patchBundledRuntime(outputDir) {
|
||||
if (hintCount > 0) {
|
||||
echo` 🩹 Patched ${hintCount} browser tool hint(s) to allow transient error retry`;
|
||||
}
|
||||
|
||||
const desktopFastChatPatches = [
|
||||
{
|
||||
label: 'desktop chat raw prompt mode',
|
||||
search: 'const contextInjectionMode = resolveContextInjectionMode(params.config);\n\t\tconst isRawModelRun = params.modelRun === true || params.promptMode === "none";',
|
||||
replace: 'const contextInjectionMode = resolveContextInjectionMode(params.config);\n\t\tconst yinianDesktopFastModelRun = (!params.messageChannel && !params.messageProvider || params.messageChannel === "webchat" || params.messageProvider === "webchat") && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");\n\t\tconst isRawModelRun = params.modelRun === true || params.promptMode === "none" || yinianDesktopFastModelRun;',
|
||||
},
|
||||
{
|
||||
label: 'desktop chat skip raw system prompt build',
|
||||
search: 'const builtAppendPrompt = resolveSystemPromptOverride({',
|
||||
replace: 'const builtAppendPrompt = isRawModelRun ? "" : resolveSystemPromptOverride({',
|
||||
},
|
||||
{
|
||||
label: 'desktop chat disable tools',
|
||||
search: 'const toolsRaw = params.disableTools || isRawModelRun ? [] : applyEmbeddedAttemptToolsAllow(createOpenClawCodingTools({',
|
||||
replace: 'const yinianDesktopFastModelRun = (!params.messageChannel && !params.messageProvider || params.messageChannel === "webchat" || params.messageProvider === "webchat") && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");\n\t\tconst toolsRaw = params.disableTools || isRawModelRun || yinianDesktopFastModelRun ? [] : applyEmbeddedAttemptToolsAllow(createOpenClawCodingTools({',
|
||||
},
|
||||
{
|
||||
label: 'desktop chat remove duplicate fast path flag',
|
||||
search: 'const yinianDesktopFastModelRun = (!params.messageChannel && !params.messageProvider || params.messageChannel === "webchat" || params.messageProvider === "webchat") && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");\n\t\tconst toolsRaw = params.disableTools || isRawModelRun || yinianDesktopFastModelRun ? [] : applyEmbeddedAttemptToolsAllow(createOpenClawCodingTools({',
|
||||
replace: 'const toolsRaw = params.disableTools || isRawModelRun || yinianDesktopFastModelRun ? [] : applyEmbeddedAttemptToolsAllow(createOpenClawCodingTools({',
|
||||
},
|
||||
{
|
||||
label: 'desktop chat webchat fast path',
|
||||
search: 'const yinianDesktopFastModelRun = !params.messageChannel && !params.messageProvider && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");',
|
||||
replace: 'const yinianDesktopFastModelRun = (!params.messageChannel && !params.messageProvider || params.messageChannel === "webchat" || params.messageProvider === "webchat") && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");',
|
||||
},
|
||||
{
|
||||
label: 'skip channel agent tools for lightweight profiles',
|
||||
search: '...listChannelAgentTools({ cfg: options?.config }),',
|
||||
replace: '...(profile === "messaging" || profile === "minimal" ? [] : listChannelAgentTools({ cfg: options?.config })),',
|
||||
},
|
||||
{
|
||||
label: 'skip plugin tools for lightweight profiles',
|
||||
search: 'allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding\n\t\t})',
|
||||
replace: 'allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,\n\t\t\tdisablePluginTools: options?.disablePluginTools ?? (profile === "messaging" || profile === "minimal")\n\t\t})',
|
||||
},
|
||||
];
|
||||
let desktopFastChatCount = 0;
|
||||
for (const patch of desktopFastChatPatches) {
|
||||
let matchedAny = false;
|
||||
if (fs.existsSync(distDir)) {
|
||||
for (const file of fs.readdirSync(distDir)) {
|
||||
if (!file.endsWith('.js')) continue;
|
||||
const filePath = path.join(distDir, file);
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
if (!content.includes(patch.search)) continue;
|
||||
matchedAny = true;
|
||||
const next = content.replace(patch.search, patch.replace);
|
||||
if (next !== content) {
|
||||
fs.writeFileSync(filePath, next, 'utf8');
|
||||
desktopFastChatCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matchedAny) {
|
||||
echo` ⚠️ Skipped patch for ${patch.label}: expected source snippet not found`;
|
||||
}
|
||||
}
|
||||
if (desktopFastChatCount > 0) {
|
||||
echo` 🩹 Patched ${desktopFastChatCount} desktop fast chat runtime site(s)`;
|
||||
}
|
||||
|
||||
const runtimeDepsTargets = findFilesByName(
|
||||
path.join(outputDir, 'dist'),
|
||||
/^bundled-runtime-deps-.*\.js$/,
|
||||
);
|
||||
const npmRunnerSearch = `\tconst execPath = params.execPath ?? process.execPath;
|
||||
\tconst existsSync = params.existsSync ?? fs.existsSync;
|
||||
\tconst platform = params.platform ?? process.platform;
|
||||
\tconst pathImpl = platform === "win32" ? path.win32 : path.posix;`;
|
||||
const npmRunnerReplace = `\tconst execPath = params.execPath ?? process.execPath;
|
||||
\tconst existsSync = params.existsSync ?? fs.existsSync;
|
||||
\tconst platform = params.platform ?? process.platform;
|
||||
\tconst pathImpl = platform === "win32" ? path.win32 : path.posix;
|
||||
\tconst env = params.env ?? process.env;
|
||||
\tconst explicitNodePath = env.YINIAN_NODE_EXEC_PATH;
|
||||
\tconst explicitNpmCliPath = env.YINIAN_NPM_CLI_PATH;
|
||||
\tif (explicitNodePath && explicitNpmCliPath && pathImpl.isAbsolute(explicitNodePath) && pathImpl.isAbsolute(explicitNpmCliPath) && existsSync(explicitNodePath) && existsSync(explicitNpmCliPath)) return {
|
||||
\t\tcommand: explicitNodePath,
|
||||
\t\targs: [explicitNpmCliPath, ...params.npmArgs]
|
||||
\t};`;
|
||||
|
||||
let npmRunnerCount = 0;
|
||||
let materializedCheckCount = 0;
|
||||
for (const target of runtimeDepsTargets) {
|
||||
const current = fs.readFileSync(target, 'utf8');
|
||||
let next = current;
|
||||
if (next.includes(npmRunnerSearch)) {
|
||||
next = next.replace(npmRunnerSearch, npmRunnerReplace);
|
||||
npmRunnerCount++;
|
||||
}
|
||||
|
||||
const materializedSearch = `function isRuntimeDepsPlanMaterialized(installRoot, installSpecs) {
|
||||
\tconst generatedManifestSpecs = readGeneratedInstallManifestSpecs(installRoot);
|
||||
\tconst packageManifestSpecs = generatedManifestSpecs !== null ? null : readPackageRuntimeDepSpecs(installRoot);
|
||||
\treturn (generatedManifestSpecs !== null && sameRuntimeDepSpecs(generatedManifestSpecs, installSpecs) || packageManifestSpecs !== null && sameRuntimeDepSpecs(packageManifestSpecs, installSpecs)) && hasSatisfiedInstallSpecPackages(installRoot, installSpecs);
|
||||
}`;
|
||||
const materializedReplace = `function isRuntimeDepsPlanMaterialized(installRoot, installSpecs) {
|
||||
\tconst generatedManifestSpecs = readGeneratedInstallManifestSpecs(installRoot);
|
||||
\tconst packageManifestSpecs = generatedManifestSpecs !== null ? null : readPackageRuntimeDepSpecs(installRoot);
|
||||
\treturn (generatedManifestSpecs !== null || packageManifestSpecs !== null) && hasSatisfiedInstallSpecPackages(installRoot, installSpecs);
|
||||
}`;
|
||||
if (next.includes(materializedSearch)) {
|
||||
next = next.replace(materializedSearch, materializedReplace);
|
||||
materializedCheckCount++;
|
||||
} else if (next.includes(materializedReplace)) {
|
||||
materializedCheckCount++;
|
||||
}
|
||||
|
||||
if (next !== current) {
|
||||
fs.writeFileSync(target, next, 'utf8');
|
||||
}
|
||||
}
|
||||
if (npmRunnerCount > 0) {
|
||||
echo` 🩹 Patched ${npmRunnerCount} bundled npm runner resolver(s)`;
|
||||
} else {
|
||||
echo` ⚠️ Skipped patch for bundled npm runner resolver: expected source snippet not found`;
|
||||
}
|
||||
if (materializedCheckCount > 0) {
|
||||
echo` 🩹 Patched ${materializedCheckCount} bundled runtime dependency materialization check(s)`;
|
||||
} else {
|
||||
echo` ⚠️ Skipped patch for bundled runtime dependency materialization check: expected source snippet not found`;
|
||||
}
|
||||
}
|
||||
|
||||
patchBrokenModules(outputNodeModules);
|
||||
patchBundledRuntime(OUTPUT);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT, YINIAN_RUNTIME_PATCH_MARKER_FILE),
|
||||
JSON.stringify(YINIAN_RUNTIME_PATCH_MARKER, null, 2) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
echo` 🧭 Wrote Yinian runtime patch marker ${YINIAN_RUNTIME_PATCH_MARKER.version}`;
|
||||
|
||||
// 8. Verify the bundle
|
||||
const entryExists = fs.existsSync(path.join(OUTPUT, 'openclaw.mjs'));
|
||||
const distExists = fs.existsSync(path.join(OUTPUT, 'dist', 'entry.js'));
|
||||
@@ -839,6 +1167,7 @@ echo``;
|
||||
echo`✅ Bundle complete: ${OUTPUT}`;
|
||||
echo` Unique packages copied: ${copiedCount}`;
|
||||
echo` Dev-only packages skipped: ${skippedDevCount}`;
|
||||
echo` Optional native packages skipped: ${skippedOptionalNativeCount}`;
|
||||
echo` Duplicate versions skipped: ${skippedDupes}`;
|
||||
echo` Total discovered: ${collected.size}`;
|
||||
echo` openclaw.mjs: ${entryExists ? '✓' : '✗'}`;
|
||||
|
||||
@@ -8,6 +8,14 @@ const BASE_URL = `https://nodejs.org/dist/v${NODE_VERSION}`;
|
||||
const OUTPUT_BASE = path.join(ROOT_DIR, 'resources', 'bin');
|
||||
|
||||
const TARGETS = {
|
||||
'darwin-x64': {
|
||||
filename: `node-v${NODE_VERSION}-darwin-x64.tar.gz`,
|
||||
sourceDir: `node-v${NODE_VERSION}-darwin-x64`,
|
||||
},
|
||||
'darwin-arm64': {
|
||||
filename: `node-v${NODE_VERSION}-darwin-arm64.tar.gz`,
|
||||
sourceDir: `node-v${NODE_VERSION}-darwin-arm64`,
|
||||
},
|
||||
'win32-x64': {
|
||||
filename: `node-v${NODE_VERSION}-win-x64.zip`,
|
||||
sourceDir: `node-v${NODE_VERSION}-win-x64`,
|
||||
@@ -19,6 +27,7 @@ const TARGETS = {
|
||||
};
|
||||
|
||||
const PLATFORM_GROUPS = {
|
||||
mac: ['darwin-x64', 'darwin-arm64'],
|
||||
win: ['win32-x64', 'win32-arm64'],
|
||||
};
|
||||
|
||||
@@ -36,11 +45,14 @@ async function setupTarget(id) {
|
||||
|
||||
echo(chalk.blue`\n📦 Setting up Node.js for ${id}...`);
|
||||
|
||||
// Only remove the target binary, not the entire directory,
|
||||
// to avoid deleting uv.exe or other binaries placed by other download scripts.
|
||||
const outputNode = path.join(targetDir, 'node.exe');
|
||||
if (await fs.pathExists(outputNode)) {
|
||||
await fs.remove(outputNode);
|
||||
const outputNode = path.join(targetDir, id.startsWith('win32-') ? 'node.exe' : 'node');
|
||||
const outputNpmDir = path.join(targetDir, 'lib', 'node_modules', 'npm');
|
||||
const outputNpmCli = path.join(outputNpmDir, 'bin', 'npm-cli.js');
|
||||
if (process.env.FORCE_BUNDLED_NODE_REFRESH !== '1'
|
||||
&& await fs.pathExists(outputNode)
|
||||
&& await fs.pathExists(outputNpmCli)) {
|
||||
echo(chalk.green`✅ Already prepared: ${outputNode}`);
|
||||
return;
|
||||
}
|
||||
await fs.remove(tempDir);
|
||||
await fs.ensureDir(targetDir);
|
||||
@@ -48,33 +60,52 @@ async function setupTarget(id) {
|
||||
|
||||
try {
|
||||
echo`⬇️ Downloading: ${downloadUrl}`;
|
||||
const response = await fetch(downloadUrl);
|
||||
if (!response.ok) throw new Error(`Failed to download: ${response.statusText}`);
|
||||
const response = await fetchWithRetry(downloadUrl);
|
||||
const buffer = await response.arrayBuffer();
|
||||
await fs.writeFile(archivePath, Buffer.from(buffer));
|
||||
|
||||
echo`📂 Extracting...`;
|
||||
if (os.platform() === 'win32') {
|
||||
if (target.filename.endsWith('.zip')) {
|
||||
const { execFileSync } = await import('child_process');
|
||||
const psCommand = `Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath.replace(/'/g, "''")}', '${tempDir.replace(/'/g, "''")}')`;
|
||||
execFileSync('powershell.exe', ['-NoProfile', '-Command', psCommand], { stdio: 'inherit' });
|
||||
if (os.platform() === 'win32') {
|
||||
const psCommand = `Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath.replace(/'/g, "''")}', '${tempDir.replace(/'/g, "''")}')`;
|
||||
execFileSync('powershell.exe', ['-NoProfile', '-Command', psCommand], { stdio: 'inherit' });
|
||||
} else {
|
||||
await $`unzip -q -o ${archivePath} -d ${tempDir}`;
|
||||
}
|
||||
} else {
|
||||
await $`unzip -q -o ${archivePath} -d ${tempDir}`;
|
||||
await $`tar -xzf ${archivePath} -C ${tempDir}`;
|
||||
}
|
||||
|
||||
const expectedNode = path.join(tempDir, target.sourceDir, 'node.exe');
|
||||
const nodeFileName = id.startsWith('win32-') ? 'node.exe' : 'node';
|
||||
const expectedNode = path.join(tempDir, target.sourceDir, 'bin', nodeFileName);
|
||||
const legacyExpectedNode = path.join(tempDir, target.sourceDir, nodeFileName);
|
||||
if (await fs.pathExists(expectedNode)) {
|
||||
await fs.move(expectedNode, outputNode, { overwrite: true });
|
||||
} else if (await fs.pathExists(legacyExpectedNode)) {
|
||||
await fs.move(legacyExpectedNode, outputNode, { overwrite: true });
|
||||
} else {
|
||||
echo(chalk.yellow`🔍 node.exe not found in expected directory, searching...`);
|
||||
const files = await glob('**/node.exe', { cwd: tempDir, absolute: true });
|
||||
echo(chalk.yellow`🔍 ${nodeFileName} not found in expected directory, searching...`);
|
||||
const files = await glob(`**/${nodeFileName}`, { cwd: tempDir, absolute: true });
|
||||
if (files.length > 0) {
|
||||
await fs.move(files[0], outputNode, { overwrite: true });
|
||||
} else {
|
||||
throw new Error('Could not find node.exe in extracted files.');
|
||||
throw new Error(`Could not find ${nodeFileName} in extracted files.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!id.startsWith('win32-')) {
|
||||
await fs.chmod(outputNode, 0o755);
|
||||
}
|
||||
|
||||
const sourceNpmDir = path.join(tempDir, target.sourceDir, 'lib', 'node_modules', 'npm');
|
||||
if (await fs.pathExists(sourceNpmDir)) {
|
||||
await fs.ensureDir(path.dirname(outputNpmDir));
|
||||
await fs.copy(sourceNpmDir, outputNpmDir, { overwrite: true });
|
||||
} else {
|
||||
echo(chalk.yellow`⚠️ npm package not found in Node archive for ${id}; only node binary was copied.`);
|
||||
}
|
||||
|
||||
echo(chalk.green`✅ Success: ${outputNode}`);
|
||||
} finally {
|
||||
await fs.remove(archivePath);
|
||||
@@ -82,11 +113,32 @@ async function setupTarget(id) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url, attempts = 3) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Failed to download: ${response.status} ${response.statusText}`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < attempts) {
|
||||
echo(chalk.yellow`⚠️ Download failed (attempt ${attempt}/${attempts}), retrying...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
const downloadAll = argv.all;
|
||||
const platform = argv.platform;
|
||||
const target = argv.target;
|
||||
|
||||
if (downloadAll) {
|
||||
echo(chalk.cyan`🌐 Downloading Node.js binaries for all Windows targets...`);
|
||||
if (target) {
|
||||
await setupTarget(String(target));
|
||||
} else if (downloadAll) {
|
||||
echo(chalk.cyan`🌐 Downloading Node.js binaries for all bundled targets...`);
|
||||
for (const id of Object.keys(TARGETS)) {
|
||||
await setupTarget(id);
|
||||
}
|
||||
@@ -104,7 +156,7 @@ if (downloadAll) {
|
||||
} else {
|
||||
const currentId = `${os.platform()}-${os.arch()}`;
|
||||
if (TARGETS[currentId]) {
|
||||
echo(chalk.cyan`💻 Detected Windows system: ${currentId}`);
|
||||
echo(chalk.cyan`💻 Detected system: ${currentId}`);
|
||||
await setupTarget(currentId);
|
||||
} else {
|
||||
echo(chalk.cyan`🎯 Defaulting to Windows multi-arch Node.js download`);
|
||||
|
||||
@@ -53,11 +53,16 @@ async function setupTarget(id) {
|
||||
const tempDir = path.join(ROOT_DIR, 'temp_uv_extract');
|
||||
const archivePath = path.join(ROOT_DIR, target.filename);
|
||||
const downloadUrl = `${BASE_URL}/${target.filename}`;
|
||||
const destBin = path.join(targetDir, target.binName);
|
||||
|
||||
echo(chalk.blue`\n📦 Setting up uv for ${id}...`);
|
||||
|
||||
// Cleanup & Prep
|
||||
await fs.remove(targetDir);
|
||||
// Cleanup temporary files only. Do not remove targetDir: it may also contain
|
||||
// the bundled Node/npm runtime used by the packaged app.
|
||||
if (process.env.FORCE_BUNDLED_UV_REFRESH !== '1' && await fs.pathExists(destBin)) {
|
||||
echo(chalk.green`✅ Already prepared: ${destBin}`);
|
||||
return;
|
||||
}
|
||||
await fs.remove(tempDir);
|
||||
await fs.ensureDir(targetDir);
|
||||
await fs.ensureDir(tempDir);
|
||||
@@ -65,8 +70,13 @@ async function setupTarget(id) {
|
||||
try {
|
||||
// Download
|
||||
echo`⬇️ Downloading: ${downloadUrl}`;
|
||||
const response = await fetch(downloadUrl);
|
||||
if (!response.ok) throw new Error(`Failed to download: ${response.statusText}`);
|
||||
let response;
|
||||
try {
|
||||
response = await fetchWithRetry(downloadUrl);
|
||||
} catch (error) {
|
||||
if (await copyLocalUvFallback(id, destBin)) return;
|
||||
throw error;
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
await fs.writeFile(archivePath, Buffer.from(buffer));
|
||||
|
||||
@@ -88,7 +98,6 @@ async function setupTarget(id) {
|
||||
// uv archives usually contain a folder named after the target
|
||||
const folderName = target.filename.replace('.tar.gz', '').replace('.zip', '');
|
||||
const sourceBin = path.join(tempDir, folderName, target.binName);
|
||||
const destBin = path.join(targetDir, target.binName);
|
||||
|
||||
if (await fs.pathExists(sourceBin)) {
|
||||
await fs.move(sourceBin, destBin, { overwrite: true });
|
||||
@@ -115,11 +124,54 @@ async function setupTarget(id) {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLocalUvFallback(id, destBin) {
|
||||
const currentId = `${os.platform()}-${os.arch()}`;
|
||||
if (id !== currentId) return false;
|
||||
|
||||
try {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const command = os.platform() === 'win32' ? 'where.exe' : 'which';
|
||||
const output = execFileSync(command, ['uv'], { encoding: 'utf8' });
|
||||
const localUv = output.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
||||
if (!localUv) return false;
|
||||
echo(chalk.yellow`⚠️ Download failed; using local uv fallback: ${localUv}`);
|
||||
await fs.copy(localUv, destBin, { overwrite: true, dereference: true });
|
||||
if (os.platform() !== 'win32') {
|
||||
await fs.chmod(destBin, 0o755);
|
||||
}
|
||||
echo(chalk.green`✅ Success: ${destBin}`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url, attempts = 3) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Failed to download: ${response.status} ${response.statusText}`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < attempts) {
|
||||
echo(chalk.yellow`⚠️ Download failed (attempt ${attempt}/${attempts}), retrying...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Main logic
|
||||
const downloadAll = argv.all;
|
||||
const platform = argv.platform;
|
||||
const target = argv.target;
|
||||
|
||||
if (downloadAll) {
|
||||
if (target) {
|
||||
await setupTarget(String(target));
|
||||
} else if (downloadAll) {
|
||||
// Download for all platforms
|
||||
echo(chalk.cyan`🌐 Downloading uv binaries for ALL supported platforms...`);
|
||||
for (const id of Object.keys(TARGETS)) {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Patch OpenClaw's BROWSER_TOOL_MODEL_HINT to allow retries on transient errors.
|
||||
* Patch OpenClaw's dev-time runtime files.
|
||||
*
|
||||
* 1. BROWSER_TOOL_MODEL_HINT: allow retries on transient errors.
|
||||
* 2. Bundled runtime deps materialization: accept already-satisfied local
|
||||
* runtime packages even when OpenClaw expands its generated manifest after
|
||||
* Gateway boot. Production builds are separately patched in
|
||||
* bundle-openclaw.mjs.
|
||||
*
|
||||
* The original hint ("Do NOT retry the browser tool — it will keep failing")
|
||||
* causes models to permanently refuse browser usage after a single transient error.
|
||||
*
|
||||
* This runs as postinstall to patch node_modules for dev mode.
|
||||
* Production builds are separately patched in bundle-openclaw.mjs.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'fs';
|
||||
@@ -26,6 +29,8 @@ const REPLACEMENTS = [
|
||||
const distDir = join(process.cwd(), 'node_modules', 'openclaw', 'dist');
|
||||
|
||||
let patchedCount = 0;
|
||||
let runtimeDepsPatchedCount = 0;
|
||||
let desktopFastChatPatchedCount = 0;
|
||||
try {
|
||||
for (const file of readdirSync(distDir)) {
|
||||
if (!file.endsWith('.js')) continue;
|
||||
@@ -48,6 +53,84 @@ try {
|
||||
// openclaw not installed yet or dist not found — skip silently
|
||||
}
|
||||
|
||||
try {
|
||||
for (const file of readdirSync(distDir)) {
|
||||
if (!file.endsWith('.js')) continue;
|
||||
const filePath = join(distDir, file);
|
||||
let content = readFileSync(filePath, 'utf-8');
|
||||
const before = content;
|
||||
|
||||
content = content.replace(
|
||||
'const contextInjectionMode = resolveContextInjectionMode(params.config);\n\t\tconst isRawModelRun = params.modelRun === true || params.promptMode === "none";',
|
||||
'const contextInjectionMode = resolveContextInjectionMode(params.config);\n\t\tconst yinianDesktopFastModelRun = (!params.messageChannel && !params.messageProvider || params.messageChannel === "webchat" || params.messageProvider === "webchat") && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");\n\t\tconst isRawModelRun = params.modelRun === true || params.promptMode === "none" || yinianDesktopFastModelRun;',
|
||||
);
|
||||
content = content.replace(
|
||||
'const builtAppendPrompt = resolveSystemPromptOverride({',
|
||||
'const builtAppendPrompt = isRawModelRun ? "" : resolveSystemPromptOverride({',
|
||||
);
|
||||
content = content.replace(
|
||||
'const toolsRaw = params.disableTools || isRawModelRun ? [] : applyEmbeddedAttemptToolsAllow(createOpenClawCodingTools({',
|
||||
'const yinianDesktopFastModelRun = (!params.messageChannel && !params.messageProvider || params.messageChannel === "webchat" || params.messageProvider === "webchat") && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");\n\t\tconst toolsRaw = params.disableTools || isRawModelRun || yinianDesktopFastModelRun ? [] : applyEmbeddedAttemptToolsAllow(createOpenClawCodingTools({',
|
||||
);
|
||||
content = content.replace(
|
||||
'const yinianDesktopFastModelRun = (!params.messageChannel && !params.messageProvider || params.messageChannel === "webchat" || params.messageProvider === "webchat") && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");\n\t\tconst toolsRaw = params.disableTools || isRawModelRun || yinianDesktopFastModelRun ? [] : applyEmbeddedAttemptToolsAllow(createOpenClawCodingTools({',
|
||||
'const toolsRaw = params.disableTools || isRawModelRun || yinianDesktopFastModelRun ? [] : applyEmbeddedAttemptToolsAllow(createOpenClawCodingTools({',
|
||||
);
|
||||
content = content.replace(
|
||||
'const yinianDesktopFastModelRun = !params.messageChannel && !params.messageProvider && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");',
|
||||
'const yinianDesktopFastModelRun = (!params.messageChannel && !params.messageProvider || params.messageChannel === "webchat" || params.messageProvider === "webchat") && (params.config?.tools?.profile === "messaging" || params.config?.tools?.profile === "minimal");',
|
||||
);
|
||||
content = content.replace(
|
||||
'...listChannelAgentTools({ cfg: options?.config }),',
|
||||
'...(profile === "messaging" || profile === "minimal" ? [] : listChannelAgentTools({ cfg: options?.config })),',
|
||||
);
|
||||
content = content.replace(
|
||||
'allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding\n\t\t})',
|
||||
'allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,\n\t\t\tdisablePluginTools: options?.disablePluginTools ?? (profile === "messaging" || profile === "minimal")\n\t\t})',
|
||||
);
|
||||
|
||||
if (content !== before) {
|
||||
writeFileSync(filePath, content, 'utf-8');
|
||||
console.log(`[patch-browser-hint] Patched desktop fast chat runtime: ${file}`);
|
||||
desktopFastChatPatchedCount++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// openclaw not installed yet or dist not found — skip silently
|
||||
}
|
||||
|
||||
try {
|
||||
for (const file of readdirSync(distDir)) {
|
||||
if (!/^bundled-runtime-deps-.*\.js$/.test(file)) continue;
|
||||
const filePath = join(distDir, file);
|
||||
let content = readFileSync(filePath, 'utf-8');
|
||||
const search = `function isRuntimeDepsPlanMaterialized(installRoot, installSpecs) {
|
||||
\tconst generatedManifestSpecs = readGeneratedInstallManifestSpecs(installRoot);
|
||||
\tconst packageManifestSpecs = generatedManifestSpecs !== null ? null : readPackageRuntimeDepSpecs(installRoot);
|
||||
\treturn (generatedManifestSpecs !== null && sameRuntimeDepSpecs(generatedManifestSpecs, installSpecs) || packageManifestSpecs !== null && sameRuntimeDepSpecs(packageManifestSpecs, installSpecs)) && hasSatisfiedInstallSpecPackages(installRoot, installSpecs);
|
||||
}`;
|
||||
const replace = `function isRuntimeDepsPlanMaterialized(installRoot, installSpecs) {
|
||||
\tconst generatedManifestSpecs = readGeneratedInstallManifestSpecs(installRoot);
|
||||
\tconst packageManifestSpecs = generatedManifestSpecs !== null ? null : readPackageRuntimeDepSpecs(installRoot);
|
||||
\treturn (generatedManifestSpecs !== null || packageManifestSpecs !== null) && hasSatisfiedInstallSpecPackages(installRoot, installSpecs);
|
||||
}`;
|
||||
if (content.includes(search)) {
|
||||
content = content.replace(search, replace);
|
||||
writeFileSync(filePath, content, 'utf-8');
|
||||
console.log(`[patch-browser-hint] Patched runtime deps materialization: ${file}`);
|
||||
runtimeDepsPatchedCount++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// openclaw not installed yet or dist not found — skip silently
|
||||
}
|
||||
|
||||
if (patchedCount > 0) {
|
||||
console.log(`[patch-browser-hint] Done. Patched ${patchedCount} file(s).`);
|
||||
}
|
||||
if (runtimeDepsPatchedCount > 0) {
|
||||
console.log(`[patch-browser-hint] Done. Patched ${runtimeDepsPatchedCount} runtime deps file(s).`);
|
||||
}
|
||||
if (desktopFastChatPatchedCount > 0) {
|
||||
console.log(`[patch-browser-hint] Done. Patched ${desktopFastChatPatchedCount} desktop fast chat file(s).`);
|
||||
}
|
||||
|
||||
299
scripts/prepare-nianxx-play-bundle.mjs
Normal file
299
scripts/prepare-nianxx-play-bundle.mjs
Normal file
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { basename, dirname, join, resolve, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
const DEFAULT_SOURCE = resolve(ROOT, '..', '..', 'NianxxPlay');
|
||||
const SOURCE_DIR = resolve(process.env.NIANXX_PLAY_DIR || DEFAULT_SOURCE);
|
||||
const OUTPUT_DIR = resolve(ROOT, 'build', 'apps', 'nianxx-play');
|
||||
const BUNDLE_RUNTIME_ENV = process.env.NIANXX_PLAY_BUNDLE_ENV === '1';
|
||||
const RUNTIME_ENV_FILE_NAME = '.env.runtime';
|
||||
|
||||
function log(message) {
|
||||
console.log(`[nianxx-play-bundle] ${message}`);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[nianxx-play-bundle] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run(command, args, cwd) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
NEXT_TELEMETRY_DISABLED: '1',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
fail(`${command} ${args.join(' ')} failed with exit code ${result.status ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readPackageVersion(packageJsonPath) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
||||
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
function parseEnvFile(envPath) {
|
||||
if (!existsSync(envPath)) return [];
|
||||
const entries = [];
|
||||
const raw = readFileSync(envPath, 'utf8');
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) continue;
|
||||
const key = match[1];
|
||||
let value = match[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
entries.push({ key, value });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function selectRuntimeEnvFile(sourceDir) {
|
||||
const explicit = process.env.NIANXX_PLAY_ENV_FILE?.trim();
|
||||
if (explicit) {
|
||||
const resolved = resolve(explicit);
|
||||
return existsSync(resolved) ? resolved : undefined;
|
||||
}
|
||||
for (const fileName of ['.env.local', '.env.production.local', '.env.production', '.env']) {
|
||||
const candidate = join(sourceDir, fileName);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function encodeEnvValue(value) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function writeRuntimeEnvFile(sourceDir, outputDir) {
|
||||
if (!BUNDLE_RUNTIME_ENV) return { bundled: false, values: 0 };
|
||||
const envFile = selectRuntimeEnvFile(sourceDir);
|
||||
if (!envFile) {
|
||||
fail('NIANXX_PLAY_BUNDLE_ENV=1 set, but no NianxxPlay env file was found.');
|
||||
}
|
||||
const entries = parseEnvFile(envFile);
|
||||
if (!entries.length) {
|
||||
fail(`NIANXX_PLAY_BUNDLE_ENV=1 set, but env file has no usable entries: ${envFile}`);
|
||||
}
|
||||
const runtimeEnvPath = join(outputDir, RUNTIME_ENV_FILE_NAME);
|
||||
const text = [
|
||||
'# Bundled only for internal testing. Do not use in production builds.',
|
||||
...entries.map((entry) => `${entry.key}=${encodeEnvValue(entry.value)}`),
|
||||
'',
|
||||
].join('\n');
|
||||
writeFileSync(runtimeEnvPath, text, 'utf8');
|
||||
return { bundled: true, values: entries.length };
|
||||
}
|
||||
|
||||
function collectSecretLikeEnvValues(sourceDir) {
|
||||
const envFileNames = [
|
||||
'.env',
|
||||
'.env.local',
|
||||
'.env.production',
|
||||
'.env.production.local',
|
||||
];
|
||||
const sensitiveKeyPattern = /(SECRET|TOKEN|PASSWORD|PRIVATE|AUTH|API_KEY|ACCESS_KEY|KEY_ID|KEY_SECRET|SERVICE_ROLE)/i;
|
||||
const ignoredValues = new Set(['true', 'false', 'null', 'undefined', 'development', 'production']);
|
||||
const values = [];
|
||||
for (const fileName of envFileNames) {
|
||||
for (const entry of parseEnvFile(join(sourceDir, fileName))) {
|
||||
if (!sensitiveKeyPattern.test(entry.key)) continue;
|
||||
if (!entry.value || entry.value.length < 8) continue;
|
||||
if (ignoredValues.has(entry.value.toLowerCase())) continue;
|
||||
values.push(entry);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function shouldCopyPublic(src) {
|
||||
const name = basename(src);
|
||||
if (name === 'uploads' || name === 'generated-results') return false;
|
||||
if (name.startsWith('.env')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldCopyRuntime(src) {
|
||||
const name = basename(src);
|
||||
if (name.startsWith('.env')) return false;
|
||||
if (name === '.data' || name === '.git' || name === '.next-cache') return false;
|
||||
if (name === 'uploads' || name === 'generated-results') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function copyDir(from, to, filter = () => true) {
|
||||
if (!existsSync(from)) return false;
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
cpSync(from, to, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function dirSizeBytes(dir) {
|
||||
if (!existsSync(dir)) return 0;
|
||||
const stats = statSync(dir);
|
||||
if (stats.isFile()) return stats.size;
|
||||
let size = 0;
|
||||
for (const entry of readdirSync(dir)) {
|
||||
size += dirSizeBytes(join(dir, entry));
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
function assertNoEnvFiles(dir) {
|
||||
const allowedRuntimeEnvPath = BUNDLE_RUNTIME_ENV
|
||||
? join(OUTPUT_DIR, RUNTIME_ENV_FILE_NAME)
|
||||
: undefined;
|
||||
const stack = [dir];
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
for (const entry of readdirSync(current)) {
|
||||
const fullPath = join(current, entry);
|
||||
if (allowedRuntimeEnvPath && fullPath === allowedRuntimeEnvPath) {
|
||||
continue;
|
||||
}
|
||||
if (basename(entry).startsWith('.env')) {
|
||||
fail(`Refusing to ship env file: ${relative(ROOT, fullPath)}`);
|
||||
}
|
||||
if (statSync(fullPath).isDirectory()) stack.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shouldScanForSecrets(filePath) {
|
||||
const stats = statSync(filePath);
|
||||
if (!stats.isFile()) return false;
|
||||
if (stats.size > 5 * 1024 * 1024) return false;
|
||||
return /\.(?:js|json|html|css|txt|mjs|cjs|map)$/i.test(filePath);
|
||||
}
|
||||
|
||||
function assertNoSecretValues(dir, secretEntries) {
|
||||
if (!secretEntries.length) return;
|
||||
const stack = [dir];
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
for (const entry of readdirSync(current)) {
|
||||
const fullPath = join(current, entry);
|
||||
const stats = statSync(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
stack.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (BUNDLE_RUNTIME_ENV && fullPath === join(OUTPUT_DIR, RUNTIME_ENV_FILE_NAME)) continue;
|
||||
if (!shouldScanForSecrets(fullPath)) continue;
|
||||
const content = readFileSync(fullPath, 'utf8');
|
||||
for (const secret of secretEntries) {
|
||||
if (content.includes(secret.value)) {
|
||||
fail(`Refusing to ship bundle: secret-like env value "${secret.key}" appears in ${relative(ROOT, fullPath)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.SKIP_NIANXX_PLAY_BUNDLE === '1') {
|
||||
log('SKIP_NIANXX_PLAY_BUNDLE=1 set, skipping.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!existsSync(join(SOURCE_DIR, 'package.json'))) {
|
||||
fail(`NianxxPlay source not found: ${SOURCE_DIR}`);
|
||||
}
|
||||
|
||||
const sourceVersion = readPackageVersion(join(SOURCE_DIR, 'package.json'));
|
||||
const secretLikeEnvValues = collectSecretLikeEnvValues(SOURCE_DIR);
|
||||
log(`source: ${SOURCE_DIR}`);
|
||||
log(`version: ${sourceVersion}`);
|
||||
|
||||
if (process.env.NIANXX_PLAY_SKIP_BUILD !== '1') {
|
||||
log('building Next.js standalone runtime...');
|
||||
run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'build'], SOURCE_DIR);
|
||||
} else {
|
||||
log('NIANXX_PLAY_SKIP_BUILD=1 set, reusing existing .next output.');
|
||||
}
|
||||
|
||||
const standaloneDir = join(SOURCE_DIR, '.next', 'standalone');
|
||||
const staticDir = join(SOURCE_DIR, '.next', 'static');
|
||||
const publicDir = join(SOURCE_DIR, 'public');
|
||||
const contentDir = join(SOURCE_DIR, 'content');
|
||||
|
||||
if (!existsSync(join(standaloneDir, 'server.js'))) {
|
||||
fail(`Missing Next.js standalone server: ${join(standaloneDir, 'server.js')}`);
|
||||
}
|
||||
if (!existsSync(staticDir)) {
|
||||
fail(`Missing Next.js static output: ${staticDir}`);
|
||||
}
|
||||
|
||||
rmSync(OUTPUT_DIR, { recursive: true, force: true });
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
log(`copying standalone runtime -> ${OUTPUT_DIR}`);
|
||||
copyDir(standaloneDir, OUTPUT_DIR, shouldCopyRuntime);
|
||||
copyDir(staticDir, join(OUTPUT_DIR, '.next', 'static'), shouldCopyRuntime);
|
||||
copyDir(publicDir, join(OUTPUT_DIR, 'public'), shouldCopyPublic);
|
||||
copyDir(contentDir, join(OUTPUT_DIR, 'content'), shouldCopyRuntime);
|
||||
const runtimeEnv = writeRuntimeEnvFile(SOURCE_DIR, OUTPUT_DIR);
|
||||
|
||||
assertNoEnvFiles(OUTPUT_DIR);
|
||||
assertNoSecretValues(OUTPUT_DIR, secretLikeEnvValues);
|
||||
|
||||
const manifest = {
|
||||
appId: 'nianxx-play',
|
||||
name: 'NianxxPlay',
|
||||
version: sourceVersion,
|
||||
bundledAt: new Date().toISOString(),
|
||||
runtime: 'next-standalone',
|
||||
entry: 'server.js',
|
||||
excludes: ['.env*', '.data', 'public/uploads', 'public/generated-results'],
|
||||
secretScan: {
|
||||
checked: true,
|
||||
sourceEnvValues: secretLikeEnvValues.length,
|
||||
},
|
||||
runtimeEnv: runtimeEnv.bundled
|
||||
? {
|
||||
bundled: true,
|
||||
file: RUNTIME_ENV_FILE_NAME,
|
||||
values: runtimeEnv.values,
|
||||
purpose: 'internal-testing-only',
|
||||
}
|
||||
: {
|
||||
bundled: false,
|
||||
},
|
||||
sizeBytes: dirSizeBytes(OUTPUT_DIR),
|
||||
};
|
||||
|
||||
writeFileSync(join(OUTPUT_DIR, 'bundle-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
log(`ready: ${OUTPUT_DIR}`);
|
||||
log(`size: ${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB`);
|
||||
Reference in New Issue
Block a user