moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,462 @@
#!/usr/bin/env node
/**
* Stage the externalized runtime dependencies of the sidecar bundle into
* app/src-tauri/resources/node_modules/ so the packaged Tauri app can resolve
* the bare require()/import() calls that build-sidecar.mjs deliberately left
* `external`. Without this, the packaged sidecar boots straight into
* MODULE_NOT_FOUND on the first eval-time external (better-sqlite3,
* @fastify/static, drizzle-orm, …).
*
* How it works:
* 1. Read the esbuild metafile written by build-sidecar.mjs to learn EXACTLY
* which external packages the bundle imports (no more guessing from the
* EXTERNAL list — some of those, e.g. mammoth/sharp, aren't actually
* reached).
* 2. Walk the transitive production-dependency closure of that set from the
* repo's own node_modules and copy each package dir verbatim — preserving
* prebuilt native .node binaries in place (better-sqlite3/build/Release,
* onnxruntime-node/bin) so require('better-sqlite3') both RESOLVES and
* FINDS its binary via the package's own relative loader.
*
* Run after build-sidecar.mjs, before `tauri build`. Arch-parameterized: honors
* TARGET_ARCH (like bundle-native-deps.mjs) to prune onnxruntime-node's
* cross-platform native binaries down to the single build target.
*
* Usage:
* node scripts/stage-sidecar-deps.mjs
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { builtinModules } from 'node:module';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const resourcesDir = path.join(root, 'app', 'src-tauri', 'resources');
// Must match the metafile path written by build-sidecar.mjs (temp, not repo).
const metaFile = path.join(os.tmpdir(), 'waggle-sidecar-meta.json');
const stageDir = path.join(resourcesDir, 'node_modules');
const hookRuntimeBuild = path.join(root, 'scripts', 'build-hook-runtime.mjs');
const HOOK_RUNTIME_ROOTS = new Set([
'@waggle/hive-mind-cli',
'@waggle/hive-mind-hooks-claude-code',
'@waggle/hive-mind-hooks-claude-desktop',
'@waggle/hive-mind-hooks-codex',
'@waggle/hive-mind-hooks-codex-desktop',
'@waggle/hive-mind-hooks-cursor',
'@waggle/hive-mind-hooks-hermes',
'@waggle/hive-mind-hooks-openclaw',
]);
const platform = process.platform;
const arch = process.env.TARGET_ARCH || process.arch;
// macOS "universal" is NOT a real staging target — onnxruntime-node's native
// binding is per-arch (bin/napi-v3/<os>/<arch>), so a universal prune keeps
// nothing. Build per-arch and lipo the app bundle instead (see release.yml).
if (arch === 'universal') {
console.error(
'[stage-sidecar-deps] FATAL — TARGET_ARCH=universal is not supported.\n'
+ ' onnxruntime-node ships a per-arch native binding; there is no universal\n'
+ ' variant to stage. Build each arch separately (TARGET_ARCH=arm64 and =x64,\n'
+ ' targets aarch64-apple-darwin / x86_64-apple-darwin) — release.yml already\n'
+ ' does this via its macOS matrix.',
);
process.exit(1);
}
// Packages we deliberately DO NOT stage even though the bundle references them.
// Each is either a guarded lazy import with graceful fallback, or verified
// unreachable on the desktop code path — staging them would add 100s of MB of
// dead weight.
// playwright-core / chromium-bidi — browser-tools.ts loads playwright-core via
// a try/catch dynamic import and returns an "npm install playwright-core"
// message when absent; the huge chromium tree is not part of boot or memory.
// onnxruntime-web — @huggingface/transformers' node build
// (dist/transformers.node.mjs) imports only onnxruntime-node +
// onnxruntime-common; the 91MB web/wasm backend is never required on Node.
// pg — a lazy dynamic import on the hosted-Postgres path only; the desktop
// sidecar uses better-sqlite3 and never reaches it (and it isn't installed).
const SKIP = new Set([
'playwright-core',
'chromium-bidi',
'@playwright/test',
'onnxruntime-web',
'pg',
]);
const BUILTINS = new Set(builtinModules);
const RUNTIME_PRUNED_DIR_NAMES = new Set([
'.github',
'__tests__',
'benchmark',
'benchmarks',
'coverage',
'example',
'examples',
'fixture',
'fixtures',
'test',
'tests',
]);
const WINDOWS_1252_EXTRA_CODEPOINTS = new Set([
0x20ac, 0x201a, 0x0192, 0x201e, 0x2026, 0x2020, 0x2021, 0x02c6, 0x2030,
0x0160, 0x2039, 0x0152, 0x017d, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022,
0x2013, 0x2014, 0x02dc, 0x2122, 0x0161, 0x203a, 0x0153, 0x017e, 0x0178,
]);
/** Map an import specifier to its top-level package name (handles scopes/subpaths). */
function toPackageName(spec) {
if (spec.startsWith('@')) {
const [scope, name] = spec.split('/');
return `${scope}/${name}`;
}
return spec.split('/')[0];
}
/** Read the metafile and return the set of external, non-builtin package names. */
function readExternalPackages() {
if (!fs.existsSync(metaFile)) {
console.error(
`[stage-sidecar-deps] FATAL — metafile not found at ${metaFile}.\n`
+ ' Run `node scripts/build-sidecar.mjs` first (it writes the metafile).',
);
process.exit(1);
}
const meta = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
const outKey = Object.keys(meta.outputs).find((k) => k.endsWith('service.js'));
if (!outKey) {
console.error('[stage-sidecar-deps] FATAL — no service.js output in metafile.');
process.exit(1);
}
const names = new Set();
for (const imp of meta.outputs[outKey].imports) {
if (!imp.external) continue;
const spec = imp.path.replace(/^node:/, '');
if (BUILTINS.has(spec)) continue;
names.add(toPackageName(imp.path));
}
return names;
}
/**
* Resolve a package's install directory as Node would from `fromDir`, walking
* up the node_modules chain. Returns the absolute dir or null if not installed
* (optional deps that npm skipped on this platform legitimately return null).
*/
function resolvePkgDir(name, fromDir) {
let dir = fromDir;
for (;;) {
const candidate = path.join(dir, 'node_modules', name);
if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
let copiedPackages = 0;
let prunedRuntimeDirs = 0;
function isWorkspacePackageDir(pkgDir) {
const realDir = fs.realpathSync.native(pkgDir);
return [path.join(root, 'packages'), path.join(root, 'apps')].some((workspaceRoot) => {
const relative = path.relative(workspaceRoot, realDir);
return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
});
}
/** Recursively copy a package dir, preserving native binaries and nested deps. */
function copyPackage(srcDir, name) {
const destDir = path.join(stageDir, name);
if (fs.existsSync(destDir)) return; // already staged (dedup by flat name)
fs.mkdirSync(path.dirname(destDir), { recursive: true });
const copyOptions = { recursive: true, dereference: true };
if (isWorkspacePackageDir(srcDir)) {
// npm does not publish a workspace package's local node_modules. Copying
// it from a dereferenced workspace symlink would leak dev-only packages;
// production dependencies are staged separately from the manifest below.
copyOptions.filter = (source) => path.basename(source) !== 'node_modules';
}
fs.cpSync(srcDir, destDir, copyOptions);
copiedPackages++;
}
function readManifest(pkgDir) {
try {
return JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
} catch {
return {};
}
}
/**
* Stage the transitive production closure of the given root package names.
* Follows `dependencies` + any `optionalDependencies` that actually resolve
* (installed on this platform). Copies each package flat into node_modules/;
* nested node_modules ride along inside their parent for version-pinned deps.
*/
function stageClosure(rootNames) {
const processedDirs = new Set();
const queue = [...rootNames].map((name) => ({ name, fromDir: root }));
while (queue.length > 0) {
const { name, fromDir } = queue.shift();
if (SKIP.has(name)) continue;
const pkgDir = resolvePkgDir(name, fromDir);
if (!pkgDir) {
// Optional/absent (e.g. bufferutil, utf-8-validate, pg): the bundle
// guards these or never reaches them — nothing to stage.
continue;
}
const pkgKey = fs.realpathSync.native(pkgDir);
if (processedDirs.has(pkgKey)) continue;
processedDirs.add(pkgKey);
copyPackage(pkgDir, name);
const manifest = readManifest(pkgDir);
const deps = { ...manifest.dependencies, ...manifest.optionalDependencies };
for (const dep of Object.keys(deps)) {
if (SKIP.has(dep)) continue;
queue.push({ name: dep, fromDir: pkgDir });
}
}
return processedDirs;
}
/**
* Prune onnxruntime-node's cross-platform native binding tree down to the
* single build target. Its loader does a hard relative require of
* `bin/napi-v3/<process.platform>/<process.arch>/onnxruntime_binding.node`, so
* only the target platform/arch dir is ever loaded — the other five (~174MB)
* are dead weight in a per-platform installer.
*/
function pruneOnnxRuntime() {
const napi = path.join(stageDir, 'onnxruntime-node', 'bin', 'napi-v3');
if (!fs.existsSync(napi)) return;
const keepPlatform = platform; // win32 | darwin | linux
const keepArch = arch === 'arm64' ? 'arm64' : 'x64';
let pruned = 0;
for (const plat of fs.readdirSync(napi)) {
const platDir = path.join(napi, plat);
if (!fs.statSync(platDir).isDirectory()) continue;
if (plat !== keepPlatform) {
fs.rmSync(platDir, { recursive: true, force: true });
pruned++;
continue;
}
for (const a of fs.readdirSync(platDir)) {
if (a !== keepArch) {
fs.rmSync(path.join(platDir, a), { recursive: true, force: true });
pruned++;
}
}
}
if (pruned > 0) {
console.log(`[stage-sidecar-deps] Pruned ${pruned} onnxruntime-node cross-platform binding dir(s); kept ${keepPlatform}/${keepArch}`);
}
}
/**
* Defense in depth: delete any SKIP-listed package that rode along inside a
* nested node_modules, so the huge trees never reach the bundle even if some
* dependency vendored them.
*/
function pruneSkipListed(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const full = path.join(dir, entry.name);
if (path.basename(dir) === 'node_modules') {
// Reconstruct the package name (scoped or plain) at this node_modules level.
if (entry.name.startsWith('@')) {
for (const sub of fs.readdirSync(full, { withFileTypes: true })) {
if (!sub.isDirectory()) continue;
const scoped = `${entry.name}/${sub.name}`;
if (SKIP.has(scoped)) {
fs.rmSync(path.join(full, sub.name), { recursive: true, force: true });
} else {
pruneSkipListed(path.join(full, sub.name));
}
}
continue;
}
if (SKIP.has(entry.name)) {
fs.rmSync(full, { recursive: true, force: true });
continue;
}
}
pruneSkipListed(full);
}
}
function isPackageContainer(dir) {
const base = path.basename(dir);
if (base === 'node_modules') return true;
return base.startsWith('@') && path.basename(path.dirname(dir)) === 'node_modules';
}
/**
* npm packages often ship tests, fixtures, examples, and CI metadata. They are
* not loaded by the packaged sidecar, and they can contain filenames that WiX
* cannot encode in the en-US MSI database codepage.
*/
function pruneRuntimeOnlyDirs(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const full = path.join(dir, entry.name);
const name = entry.name.toLowerCase();
if (!isPackageContainer(dir) && RUNTIME_PRUNED_DIR_NAMES.has(name)) {
fs.rmSync(full, { recursive: true, force: true });
prunedRuntimeDirs++;
continue;
}
pruneRuntimeOnlyDirs(full);
}
}
function isWindows1252PathSafe(value) {
for (const char of value) {
const code = char.codePointAt(0) || 0;
if (code <= 0x7f || (code >= 0xa0 && code <= 0xff)) continue;
if (WINDOWS_1252_EXTRA_CODEPOINTS.has(code)) continue;
return false;
}
return true;
}
function listFiles(dir) {
const files = [];
const stack = [dir];
while (stack.length) {
const current = stack.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) stack.push(full);
else if (entry.isFile()) files.push(full);
}
}
return files;
}
function listPackageDirs(nodeModulesDir) {
if (!fs.existsSync(nodeModulesDir)) return [];
const packageDirs = [];
const stack = [nodeModulesDir];
while (stack.length) {
const current = stack.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const full = path.join(current, entry.name);
if (fs.existsSync(path.join(full, 'package.json'))) {
packageDirs.push(full);
}
stack.push(full);
}
}
return packageDirs;
}
function resolveWithinStagedResources(fromPackageDir, dep) {
let current = fromPackageDir;
for (;;) {
const candidate = path.join(current, 'node_modules', ...dep.split('/'), 'package.json');
if (fs.existsSync(candidate)) return true;
if (path.resolve(current) === path.resolve(resourcesDir)) return false;
const parent = path.dirname(current);
if (parent === current) return false;
current = parent;
}
}
function assertStagedNodeModulesSelfContained() {
const missing = [];
for (const packageDir of listPackageDirs(stageDir)) {
const manifest = readManifest(packageDir);
for (const dep of Object.keys(manifest.dependencies || {})) {
if (SKIP.has(dep)) continue;
if (!resolveWithinStagedResources(packageDir, dep)) {
missing.push(`${path.relative(stageDir, packageDir)} -> ${dep}`);
}
}
}
if (missing.length === 0) return;
console.error(
'[stage-sidecar-deps] FATAL - staged node_modules is not self-contained:\n'
+ missing.map((dep) => ` - ${dep}`).join('\n')
+ '\n Add the missing transitive runtime dependency to the staged closure.',
);
process.exit(1);
}
function assertWindowsMsiSafeResourcePaths() {
if (platform !== 'win32') return;
const unsafe = listFiles(resourcesDir)
.map((file) => path.relative(resourcesDir, file))
.filter((file) => !isWindows1252PathSafe(file));
if (unsafe.length === 0) return;
console.error(
'[stage-sidecar-deps] FATAL - staged resource paths are not Windows MSI codepage-safe:\n'
+ unsafe.map((file) => ` - ${file}`).join('\n')
+ '\n Prune the package payload or configure an MSI codepage before bundling.',
);
process.exit(1);
}
function dirSizeMB(dir) {
let bytes = 0;
const stack = [dir];
while (stack.length) {
const d = stack.pop();
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
if (e.isDirectory()) stack.push(p);
else if (e.isFile()) {
try { bytes += fs.statSync(p).size; } catch { /* transient */ }
}
}
}
return (bytes / 1024 / 1024).toFixed(1);
}
// ── main ───────────────────────────────────────────────────────────
console.log(`[stage-sidecar-deps] Platform: ${platform}-${arch}`);
// These workspace packages are loaded by hook installers/external agents, not
// by the sidecar bundle itself, so the esbuild metafile cannot discover them.
// Build them explicitly before copying their production dependency closure.
execFileSync(process.execPath, [hookRuntimeBuild], { cwd: root, stdio: 'inherit' });
// Fresh stage dir each run so a removed dep never lingers in a stale bundle.
fs.rmSync(stageDir, { recursive: true, force: true });
fs.mkdirSync(stageDir, { recursive: true });
const externals = readExternalPackages();
const runtimeRoots = new Set([...externals, ...HOOK_RUNTIME_ROOTS]);
const staged = [...runtimeRoots].filter((n) => !SKIP.has(n)).sort();
const skipped = [...externals].filter((n) => SKIP.has(n)).sort();
console.log(`[stage-sidecar-deps] Bundle/runtime roots: ${runtimeRoots.size} (${staged.length} to stage, ${skipped.length} skipped)`);
if (skipped.length) console.log(`[stage-sidecar-deps] skipped: ${skipped.join(', ')}`);
const closure = stageClosure(runtimeRoots);
pruneOnnxRuntime();
pruneSkipListed(stageDir);
pruneRuntimeOnlyDirs(stageDir);
if (prunedRuntimeDirs > 0) {
console.log(`[stage-sidecar-deps] Pruned ${prunedRuntimeDirs} runtime-unused package artifact dir(s)`);
}
assertWindowsMsiSafeResourcePaths();
assertStagedNodeModulesSelfContained();
console.log(
`[stage-sidecar-deps] Staged ${copiedPackages} packages `
+ `(${closure.size} in closure) → resources/node_modules/ (${dirSizeMB(stageDir)} MB)`,
);