feat: prepare Zhinian desktop pilot
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-05-07 21:49:20 +08:00
parent cddaf37016
commit 0abc48189c
103 changed files with 10975 additions and 2049 deletions

View File

@@ -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' },
];

View File

@@ -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) {

View File

@@ -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 ? '✓' : '✗'}`;

View File

@@ -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`);

View File

@@ -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)) {

View File

@@ -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).`);
}

View 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`);