/** * Electron Forge config for peardata-client. * * CI note: ignore list strips server/tooling deps. * Only ship prebuilds for the package target platform/arch. * * QVAC: when `@qvac/sdk` is installed, `QvacForgePlugin` bundles the Bare worker, * forces asar:false (required for .bare addons), and prunes foreign prebuilds. * Set PEARDATA_SKIP_QVAC=1 to package a tools-only client without native QVAC. * * @see https://docs.qvac.tether.io/tutorials/electron */ 'use strict' const path = require('path') const fs = require('fs') const pkg = require('./package.json') // Use lowercase package name for out/-/ so release staging is stable. // productName (PearData) remains the display name in package.json / UI. const appName = pkg.name || 'peardata' /** * Resolve packaging target from forge CLI args or env (set by make.cjs). * @returns {{ platform: string, arch: string }} */ function resolvePackageTarget() { const argv = process.argv const flag = (name) => { const i = argv.indexOf(name) return i >= 0 && argv[i + 1] ? argv[i + 1] : null } const platform = process.env.PEARDATA_PACKAGE_PLATFORM || flag('--platform') || process.platform const arch = process.env.PEARDATA_PACKAGE_ARCH || flag('--arch') || process.arch return { platform, arch } } const packageTarget = resolvePackageTarget() const packageHost = `${packageTarget.platform}-${packageTarget.arch}` /** Hosts with published @qvac/llm-llamacpp Bare prebuilds (see scripts/hosts.cjs). */ const QVAC_NATIVE_HOSTS = new Set([ 'linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'win32-x64', ]) const skipQvacEnv = process.env.PEARDATA_SKIP_QVAC === '1' || process.env.PEARDATA_SKIP_QVAC === 'true' const skipQvacHost = !QVAC_NATIVE_HOSTS.has(packageHost) const skipQvac = skipQvacEnv || skipQvacHost if (skipQvacHost && !skipQvacEnv) { console.warn( `[forge] target ${packageHost} has no QVAC llm-llamacpp prebuild — packaging tools-only client` ) } /** @type {typeof import('@qvac/sdk/electron-forge')|null} */ let QvacForgePlugin = null if (!skipQvac) { try { // Prefer THIS project's node_modules (never a parent hoist of an older SDK). const forgePath = require.resolve('@qvac/sdk/electron-forge', { paths: [__dirname], }) QvacForgePlugin = require(forgePath) } catch (err) { console.warn( '[forge] @qvac/sdk/electron-forge not available — packaging tools-only client.', err?.message || err ) } } const qvacEnabled = Boolean(QvacForgePlugin) /** Path prefixes (packager paths start with /) to exclude from the app bundle */ const IGNORE_PREFIXES = [ '/.git', '/.gitea', '/.github', '/out', '/dist', '/deploy', '/test', '/docs', '/spec', '/server', '/bin', '/scripts', '/tools', '/.cache', '/build/stubs', '/build/shims', '/README.md', '/LICENSE', // Packaging / Bare server toolchain (not needed at Electron runtime) '/node_modules/electron', '/node_modules/electron-', '/node_modules/@electron', '/node_modules/@electron-forge', '/node_modules/bare-build', // bare-runtime is REQUIRED when QVAC is packaged (spawns Bare worker) ...(qvacEnabled ? [] : ['/node_modules/bare-runtime']), '/node_modules/bare-sidecar', // bare-link / bare-pack may be used by QVAC worker packaging — keep when QVAC on ...(qvacEnabled ? [] : ['/node_modules/bare-link', '/node_modules/bare-pack']), '/node_modules/bare-lief', '/node_modules/bare-apk', '/node_modules/bare-app-image', '/node_modules/bare-make', '/node_modules/bare-dev', '/node_modules/bare-bundle', '/node_modules/bare-module-traverse', '/node_modules/bare-sqlite', '/node_modules/postject', '/node_modules/@inquirer', '/node_modules/terser', '/node_modules/pear-runtime/', '/node_modules/pear-electron', // Heavy / unused tooling '/node_modules/typescript', '/node_modules/prettier', '/node_modules/webpack', '/node_modules/caniuse-lite', '/node_modules/brittle', '/node_modules/@types', '/node_modules/esbuild', '/node_modules/@esbuild', '/node_modules/node-gyp', '/node_modules/node-addon-api', ] const IGNORE_REGEX = [ /^\/node_modules\/bare-build-/, // bare-runtime- handled specially below (QVAC needs target host binary) /^\/node_modules\/bare-pack-/, /^\/node_modules\/@esbuild\//, /\.md$/i, /\.map$/, /\.d\.ts$/, /^\/peardata-.*\.json$/, /^\/\.env$/, /^\/package-lock\.json$/, /^\/node_modules\/[^/]+\/test\//, /^\/node_modules\/[^/]+\/tests\//, /^\/node_modules\/[^/]+\/docs\//, /^\/node_modules\/[^/]+\/example\//, /^\/node_modules\/[^/]+\/examples\//, /^\/node_modules\/[^/]+\/\.github\//, ] /** * bare-runtime-darwin-arm64 etc. — keep only the package for the forge target host when QVAC is on. * @param {string} file */ function isStrippedBareRuntimePlatform(file) { if (!file.startsWith('/node_modules/bare-runtime-')) return false // e.g. /node_modules/bare-runtime-darwin-arm64/... const seg = file.split('/')[2] || '' if (!seg.startsWith('bare-runtime-')) return false if (!qvacEnabled) return true const need = `bare-runtime-${packageHost}` return seg !== need } function isForeignPrebuild(file) { const marker = '/prebuilds/' const idx = file.indexOf(marker) if (idx === -1) return false const host = file.slice(idx + marker.length).split('/')[0] if (!host) return false if ( host.startsWith('android') || host.startsWith('ios') || host.includes('simulator') ) { return true } return host !== packageHost } function shouldIgnore(file) { if (!file) return false if (file === '/package.json') return false if (file === '/electron/app.bundle.cjs') return false if (file === '/qvac.config.json') return false // Always keep QVAC worker entries + config for packaged SDK resolution if (file === '/qvac' || file.startsWith('/qvac/')) return false if (file === '/electron/app.bundle.cjs.map') { return process.env.PEARDATA_KEEP_SOURCEMAP !== '1' } // Never strip @qvac/* when QVAC packaging is enabled (plugin also tree-shakes) if (qvacEnabled && (file === '/node_modules/@qvac' || file.startsWith('/node_modules/@qvac/'))) { // Still drop foreign prebuilds under @qvac if (isForeignPrebuild(file)) return true return false } // Keep bare-runtime + bare-runtime- for QVAC Bare worker spawn if (qvacEnabled) { if (file === '/node_modules/bare-runtime' || file.startsWith('/node_modules/bare-runtime/')) { return false } const need = `/node_modules/bare-runtime-${packageHost}` if (file === need || file.startsWith(need + '/')) return false } if (isStrippedBareRuntimePlatform(file)) return true for (const p of IGNORE_PREFIXES) { if (file === p || file.startsWith(p + '/') || file.startsWith(p)) return true } for (const re of IGNORE_REGEX) { if (re.test(file)) return true } if (isForeignPrebuild(file)) return true return false } const skipRebuild = process.env.PEARDATA_FORCE_REBUILD !== '1' && process.env.PEARDATA_SKIP_REBUILD !== '0' const electronZipDir = path.join(__dirname, '.cache', 'electron-zips') const useElectronZipDir = process.env.PEARDATA_USE_ELECTRON_ZIP_DIR !== '0' && fs.existsSync(electronZipDir) /** * Copy bare-runtime + bare-runtime- (+ require-asset) into the staged app * if packager omit them. Used when QVAC is enabled. * @param {string} buildPath * @param {string} host e.g. darwin-arm64 */ function ensureBareRuntimeInBuild(buildPath, host) { const nm = path.join(buildPath, 'node_modules') const srcNm = path.join(__dirname, 'node_modules') function copyPkg(name) { const src = path.join(srcNm, name) const dest = path.join(nm, name) if (!fs.existsSync(src)) { console.warn(`[forge] cannot copy ${name}: not in project node_modules`) return false } if (fs.existsSync(dest)) { // Ensure binary present even if dir exists empty-ish return true } fs.mkdirSync(nm, { recursive: true }) fs.cpSync(src, dest, { recursive: true }) console.log(`[forge] copied ${name} into package node_modules`) return true } copyPkg('bare-runtime') copyPkg(`bare-runtime-${host}`) // Platform package depends on require-asset (and its chain) for (const dep of ['require-asset', 'bare-module-resolve', 'bare-semver']) { if (fs.existsSync(path.join(srcNm, dep))) copyPkg(dep) } // Nest under bare-runtime/node_modules for require() from bare-runtime/index.js const nestedParent = path.join(nm, 'bare-runtime', 'node_modules') const nestedDest = path.join(nestedParent, `bare-runtime-${host}`) const topDest = path.join(nm, `bare-runtime-${host}`) if (fs.existsSync(topDest) && !fs.existsSync(nestedDest)) { try { fs.mkdirSync(nestedParent, { recursive: true }) fs.cpSync(topDest, nestedDest, { recursive: true }) console.log(`[forge] nested bare-runtime-${host} under bare-runtime/node_modules`) } catch (err) { console.warn(`[forge] nest bare-runtime-${host} failed:`, err?.message || err) } } // Electron packager / copy can drop +x on the Bare binary const binName = host.startsWith('win32') ? 'bare.exe' : 'bare' for (const base of [topDest, nestedDest]) { const bin = path.join(base, 'bin', binName) if (fs.existsSync(bin)) { try { fs.chmodSync(bin, 0o755) } catch { // ignore } } } } function stripBuildPath(buildPath) { const t0 = Date.now() let removed = 0 function rm(rel) { const p = path.join(buildPath, rel) try { if (fs.existsSync(p)) { fs.rmSync(p, { recursive: true, force: true }) removed++ } } catch { // ignore } } const junk = [ 'node_modules/bare-sidecar', 'node_modules/electron', 'node_modules/@electron-forge', 'node_modules/bare-build', // Keep bare-runtime when packaging QVAC (Bare worker spawn) ...(qvacEnabled ? [] : ['node_modules/bare-runtime']), 'node_modules/pear-runtime', 'node_modules/pear-electron', 'node_modules/esbuild', 'node_modules/bare-sqlite', 'node_modules/postject', 'node_modules/@inquirer', 'node_modules/terser', 'server', 'bin', 'scripts', 'spec', 'deploy', 'out', 'test', 'docs', 'tools', '.cache', 'README.md', 'LICENSE', ] for (const rel of junk) rm(rel) const nm = path.join(buildPath, 'node_modules') if (fs.existsSync(nm)) { const stack = [nm] const seen = new Set() while (stack.length) { const dir = stack.pop() let real try { real = fs.realpathSync(dir) } catch { continue } if (seen.has(real)) continue seen.add(real) let entries try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { continue } for (const ent of entries) { const full = path.join(dir, ent.name) if (ent.isSymbolicLink()) continue if (!ent.isDirectory()) continue if (ent.name === 'prebuilds') { let hosts try { hosts = fs.readdirSync(full) } catch { continue } for (const host of hosts) { if (host === packageHost) continue try { fs.rmSync(path.join(full, host), { recursive: true, force: true }) removed++ } catch { // ignore } } continue } if (ent.name === '.bin') continue stack.push(full) } } } if (process.env.PEARDATA_KEEP_SOURCEMAP !== '1') { rm('electron/app.bundle.cjs.map') } console.log( `[forge] packageAfterCopy target=${packageHost} stripped=${removed} in ${Date.now() - t0}ms` ) } module.exports = { packagerConfig: { name: appName, executableName: 'peardata-client', appBundleId: 'com.peardata.app', icon: fs.existsSync(path.join(__dirname, 'build', 'icon.png')) ? path.join(__dirname, 'build', 'icon') : undefined, // QvacForgePlugin forces asar:false when QVAC is enabled (Bare cannot load from asar). // Without QVAC, keep asar + native unpack for smaller zips. asar: qvacEnabled ? false : { unpack: '**/*.{node,bare,dll,dylib,so}', }, ignore: shouldIgnore, derefSymlinks: false, prune: false, ...(useElectronZipDir ? { electronZipDir } : {}), quiet: process.env.CI ? false : true, osxSign: false, }, rebuildConfig: skipRebuild ? { onlyModules: [], force: false } : { force: false, onlyModules: [ 'udx-native', 'sodium-native', 'rocksdb-native', 'fs-native-extensions', 'quickbit-native', 'simdle-native', 'bare-fs', 'bare-os', 'bare-crypto', ], }, makers: [ { name: '@electron-forge/maker-zip', platforms: ['darwin', 'linux', 'win32'], }, ], plugins: qvacEnabled ? [ new QvacForgePlugin({ projectDir: __dirname, configPath: path.join(__dirname, 'qvac.config.json'), // Host comes from electron-forge --platform/--arch (make.cjs sets these) logLevel: process.env.CI ? 'info' : 'info', }), ] : [], hooks: { prePackage: async () => { console.log(`[forge] packaging target host: ${packageHost}`) console.log( `[forge] QVAC: ${qvacEnabled ? 'enabled (@qvac/sdk/electron-forge)' : 'skipped (tools-only client)'}` ) console.log( `[forge] electronZipDir: ${useElectronZipDir ? electronZipDir : '(none — packager will download)'}` ) console.log( `[forge] rebuild: ${skipRebuild ? 'skip (onlyModules:[])' : 'enabled'}` ) // QVAC Bare worker needs bare-runtime + platform binary in the app package. // On Linux CI, optional deps skip darwin/win32 — force-install the target host. if (qvacEnabled) { try { require('child_process').execFileSync( process.execPath, [ path.join(__dirname, 'scripts', 'ensure-qvac-bare-runtimes.cjs'), '--hosts', packageHost, ], { stdio: 'inherit', cwd: __dirname } ) } catch (err) { throw new Error( `[forge] failed to ensure bare-runtime-${packageHost} for QVAC: ${err?.message || err}` ) } // bundleSdk (QvacForgePlugin) may regenerate absolute file:// imports — // always re-write a portable entry before packaging copies files. try { require('./scripts/rewrite-qvac-worker-entry.cjs').ensurePortableWorkerEntry({ root: __dirname, forcePortable: true, }) } catch (err) { console.warn('[forge] prePackage portable worker rewrite:', err?.message || err) } } if (process.env.PEARDATA_SKIP_PREPACKAGE_BUNDLE === '1') { const bundle = path.join(__dirname, 'electron', 'app.bundle.cjs') if (fs.existsSync(bundle)) { console.log('[forge] prePackage: skip GUI bundle (already built)') return } } require('child_process').execFileSync( process.execPath, [path.join(__dirname, 'scripts', 'build-client-bundle.cjs')], { stdio: 'inherit', cwd: __dirname } ) }, preMake: async () => { fs.rmSync(path.join(__dirname, 'out', 'make'), { recursive: true, force: true }) }, packageAfterCopy: async (_forgeConfig, buildPath, _electronVersion, platform, arch) => { // Prefer packager's real platform/arch (authoritative for this package). const host = platform && arch ? `${platform}-${arch}` : packageHost const pkgPath = path.join(buildPath, 'package.json') const appPkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) appPkg.main = 'electron/main.cjs' delete appPkg.devDependencies delete appPkg.scripts if (appPkg.dependencies) { const drop = ['bare-build', 'bare-sqlite', 'pear-electron'] // bare-runtime stays when QVAC ships (worker spawn) if (!qvacEnabled) drop.push('bare-runtime') for (const name of drop) { delete appPkg.dependencies[name] } } fs.writeFileSync(pkgPath, JSON.stringify(appPkg, null, 2) + '\n') // Ensure worker entry exists for SDK resolvePackagedWorkerPath() const workerCandidates = [ path.join(buildPath, 'qvac', 'worker.entry.mjs'), path.join(buildPath, 'qvac', 'worker.bundle.js'), ] const hasWorker = workerCandidates.some((p) => fs.existsSync(p)) if (qvacEnabled && !hasWorker) { console.warn( '[forge] warning: no qvac worker entry in package — full LLM may fail; tools-only still works' ) } // Portable worker.entry.mjs: strip absolute file:///build-machine paths // (bundleSdk emits those; Bare then dies → RPC init timeout 30s) if (qvacEnabled) { try { const { ensurePortableWorkerEntry } = require('./scripts/rewrite-qvac-worker-entry.cjs') const wr = ensurePortableWorkerEntry({ root: buildPath, forcePortable: true, }) console.log( `[forge] portable QVAC worker entry: ${wr.reason || 'ok'} → ${wr.path}` ) } catch (err) { console.warn('[forge] rewrite-qvac-worker-entry failed:', err?.message || err) } } // --- bare-runtime platform binary (QVAC issue #1492) ------------------- // If the platform package was missed by ignore/prune, copy it from the // project tree. Fail hard when QVAC is enabled so CI never ships a // "tools-only with cryptic bare-runtime error" client. if (qvacEnabled) { ensureBareRuntimeInBuild(buildPath, host) } stripBuildPath(buildPath) // Re-check after strip (strip must not remove target host runtime) if (qvacEnabled) { const binName = host.startsWith('win32') ? 'bare.exe' : 'bare' const bareBin = path.join( buildPath, 'node_modules', `bare-runtime-${host}`, 'bin', binName ) const bareMeta = path.join(buildPath, 'node_modules', 'bare-runtime', 'package.json') if (!fs.existsSync(bareMeta) || !fs.existsSync(bareBin)) { throw new Error( `[forge] QVAC package missing bare-runtime for ${host}.\n` + ` bare-runtime: ${fs.existsSync(bareMeta) ? 'ok' : 'MISSING'}\n` + ` ${bareBin}: ${fs.existsSync(bareBin) ? 'ok' : 'MISSING'}\n` + ` Run: node scripts/ensure-qvac-bare-runtimes.cjs --hosts ${host}\n` + ` See https://github.com/tetherto/qvac/issues/1492` ) } console.log(`[forge] verified bare-runtime-${host} binary in package`) } // Fail if worker still has build-machine absolute imports if (qvacEnabled) { const entry = path.join(buildPath, 'qvac', 'worker.entry.mjs') if (fs.existsSync(entry)) { const body = fs.readFileSync(entry, 'utf8') if (/file:\/\/\/Users\/|file:\/\/\/home\/|file:\/\/\/[A-Za-z]:\//.test(body)) { throw new Error( `[forge] qvac/worker.entry.mjs still has absolute file:// imports — ` + `Bare worker will time out on other machines. Run: ` + `node scripts/rewrite-qvac-worker-entry.cjs --force-portable` ) } console.log('[forge] verified portable qvac/worker.entry.mjs (no absolute file://)') } else { throw new Error( `[forge] missing qvac/worker.entry.mjs in package — Bare RPC cannot start` ) } // Ensure @qvac/sdk paths the worker imports exist const needFiles = [ 'node_modules/@qvac/sdk/dist/server/worker-core.js', 'node_modules/@qvac/sdk/dist/server/plugins/index.js', 'node_modules/@qvac/sdk/dist/logging/index.js', 'node_modules/@qvac/sdk/dist/server/bare/plugins/llamacpp-completion/plugin.js', ] for (const rel of needFiles) { if (!fs.existsSync(path.join(buildPath, rel))) { throw new Error( `[forge] QVAC package missing ${rel} (required by portable worker entry)` ) } } } }, postPackage: async (_forgeConfig, options) => { const platform = options.platform || process.platform if (platform !== 'darwin') return const { signApp, findApps } = require('./scripts/sign-macos-app.cjs') const paths = options.outputPaths || [] for (const outPath of paths) { const apps = findApps(outPath) for (const app of apps) { console.log('[forge] postPackage codesign', app) await signApp(app) } } }, }, }