318 lines
9.0 KiB
JavaScript
318 lines
9.0 KiB
JavaScript
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { spawnSync } from 'node:child_process'
|
|
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
|
const packageRoot = resolve(scriptDir, '..')
|
|
const repoRoot = resolve(packageRoot, '..', '..')
|
|
const runtimeGatewayDir = join(packageRoot, 'runtime', 'gateway')
|
|
const pyinstallerWorkDir = join(packageRoot, '.pyinstaller')
|
|
const entryPath = join(scriptDir, 'gateway-entry.py')
|
|
const controlUiDistDir = join(repoRoot, 'src', 'opensquilla', 'gateway', 'static', 'dist')
|
|
const routerBundleDir = join(repoRoot, 'src', 'opensquilla', 'squilla_router', 'models', 'v4.2_phase3_inference')
|
|
const addDataSeparator = process.platform === 'win32' ? ';' : ':'
|
|
const gitLfsPointerHeader = 'version https://git-lfs.github.com/spec/v1'
|
|
|
|
function findFilesByName(root, fileName) {
|
|
const matches = []
|
|
|
|
function walk(dir) {
|
|
let entries
|
|
try {
|
|
entries = readdirSync(dir, { withFileTypes: true })
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const path = join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
walk(path)
|
|
} else if (entry.isFile() && entry.name === fileName) {
|
|
matches.push(path)
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(root)
|
|
return matches
|
|
}
|
|
|
|
function findMacLibomp() {
|
|
const candidates = []
|
|
candidates.push(...findFilesByName(runtimeGatewayDir, 'libomp.dylib'))
|
|
|
|
const brew = spawnSync('brew', ['--prefix', 'libomp'], {
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
})
|
|
|
|
if (brew.status === 0) {
|
|
const prefix = brew.stdout.trim()
|
|
if (prefix) candidates.push(join(prefix, 'lib', 'libomp.dylib'))
|
|
}
|
|
|
|
candidates.push(
|
|
'/opt/homebrew/opt/libomp/lib/libomp.dylib',
|
|
'/usr/local/opt/libomp/lib/libomp.dylib',
|
|
'/opt/local/lib/libomp/libomp.dylib',
|
|
)
|
|
|
|
return candidates.find((candidate) => existsSync(candidate))
|
|
}
|
|
|
|
function signMacBinary(path) {
|
|
if (process.platform !== 'darwin') return
|
|
const result = spawnSync('codesign', ['--force', '--sign', '-', path], {
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
})
|
|
if (result.error) throw result.error
|
|
if (result.status !== 0) {
|
|
throw new Error(`codesign failed for ${path} with exit ${result.status}:\n${result.stderr || result.stdout}`)
|
|
}
|
|
}
|
|
|
|
function pythonPackageFile(packageName, relativePath) {
|
|
const code = [
|
|
'import importlib.util',
|
|
'import pathlib',
|
|
'import sys',
|
|
`spec = importlib.util.find_spec(${JSON.stringify(packageName)})`,
|
|
'if spec is None or spec.origin is None:',
|
|
' sys.exit(1)',
|
|
`path = pathlib.Path(spec.origin).parent / ${JSON.stringify(relativePath)}`,
|
|
'if not path.exists():',
|
|
' sys.exit(2)',
|
|
'print(path)',
|
|
].join('\n')
|
|
const result = spawnSync(
|
|
'uv',
|
|
['run', '--extra', 'recommended', 'python', '-c', code],
|
|
{
|
|
cwd: repoRoot,
|
|
env: {
|
|
...process.env,
|
|
PYTHONUNBUFFERED: '1',
|
|
PYTHONUTF8: '1',
|
|
PYTHONIOENCODING: 'utf-8:replace',
|
|
},
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
},
|
|
)
|
|
if (result.error) throw result.error
|
|
if (result.status !== 0) {
|
|
throw new Error(
|
|
`Could not locate ${packageName}/${relativePath} for the desktop gateway bundle.\n${result.stderr || result.stdout}`,
|
|
)
|
|
}
|
|
|
|
const path = result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1)
|
|
if (!path || !existsSync(path)) {
|
|
throw new Error(`Resolved ${packageName}/${relativePath} to an invalid path: ${path || '<empty>'}`)
|
|
}
|
|
return path
|
|
}
|
|
|
|
function addBinaryArg(sourcePath, destinationDir) {
|
|
return ['--add-binary', `${sourcePath}${addDataSeparator}${destinationDir}`]
|
|
}
|
|
|
|
function platformLightgbmLibraryPath() {
|
|
if (process.platform === 'win32') return join('bin', 'lib_lightgbm.dll')
|
|
if (process.platform === 'darwin') return join('lib', 'lib_lightgbm.dylib')
|
|
return join('lib', 'lib_lightgbm.so')
|
|
}
|
|
|
|
function platformLightgbmBundleDir() {
|
|
return process.platform === 'win32' ? 'lightgbm/bin' : 'lightgbm/lib'
|
|
}
|
|
|
|
function readFileHead(path, bytes = 96) {
|
|
return readFileSync(path).subarray(0, bytes).toString('utf8')
|
|
}
|
|
|
|
function assertRouterAssetsReady() {
|
|
const manifestPath = join(routerBundleDir, 'artifact_manifest.json')
|
|
if (!existsSync(manifestPath)) {
|
|
throw new Error(`Router artifact manifest not found at ${manifestPath}.`)
|
|
}
|
|
|
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
const problems = []
|
|
for (const file of manifest.files || []) {
|
|
const relPath = String(file.path || '')
|
|
if (!relPath) continue
|
|
const path = join(routerBundleDir, relPath)
|
|
if (!existsSync(path)) {
|
|
problems.push(`${relPath}: missing`)
|
|
continue
|
|
}
|
|
const actualSize = statSync(path).size
|
|
if (typeof file.size_bytes === 'number' && actualSize !== file.size_bytes) {
|
|
problems.push(`${relPath}: size ${actualSize} != manifest ${file.size_bytes}`)
|
|
continue
|
|
}
|
|
if (readFileHead(path).startsWith(gitLfsPointerHeader)) {
|
|
problems.push(`${relPath}: Git LFS pointer file, not the real router artifact`)
|
|
}
|
|
}
|
|
|
|
if (problems.length > 0) {
|
|
throw new Error(
|
|
[
|
|
'Router V4 Phase 3 assets are incomplete; refusing to build a desktop gateway that degrades routing.',
|
|
'Run `git lfs pull --include="src/opensquilla/squilla_router/models/v4.2_phase3_inference/**"` and rebuild.',
|
|
...problems.map((problem) => `- ${problem}`),
|
|
].join('\n'),
|
|
)
|
|
}
|
|
}
|
|
|
|
function patchMacLightgbmRuntime() {
|
|
if (process.platform !== 'darwin') return
|
|
|
|
const lightgbmLibs = findFilesByName(runtimeGatewayDir, 'lib_lightgbm.dylib')
|
|
if (lightgbmLibs.length === 0) {
|
|
throw new Error('LightGBM was requested for the desktop gateway, but lib_lightgbm.dylib was not bundled.')
|
|
}
|
|
|
|
const libomp = findMacLibomp()
|
|
if (!libomp) {
|
|
throw new Error(
|
|
'macOS LightGBM runtime requires libomp.dylib. Ensure scikit-learn is bundled, or install libomp on the build host, for example `brew install libomp`, then rebuild the desktop gateway.',
|
|
)
|
|
}
|
|
|
|
for (const lightgbmLib of lightgbmLibs) {
|
|
const bundledLibomp = join(dirname(lightgbmLib), 'libomp.dylib')
|
|
copyFileSync(libomp, bundledLibomp)
|
|
signMacBinary(bundledLibomp)
|
|
|
|
const result = spawnSync(
|
|
'install_name_tool',
|
|
['-change', '@rpath/libomp.dylib', '@loader_path/libomp.dylib', lightgbmLib],
|
|
{ encoding: 'utf8', windowsHide: true },
|
|
)
|
|
if (result.error) throw result.error
|
|
if (result.status !== 0) {
|
|
throw new Error(
|
|
`install_name_tool failed for ${lightgbmLib} with exit ${result.status}:\n${result.stderr || result.stdout}`,
|
|
)
|
|
}
|
|
signMacBinary(lightgbmLib)
|
|
}
|
|
}
|
|
|
|
if (!existsSync(join(controlUiDistDir, 'index.html'))) {
|
|
throw new Error(`Built Control UI not found at ${controlUiDistDir}. Run npm run build:web before npm run build:gateway.`)
|
|
}
|
|
assertRouterAssetsReady()
|
|
|
|
rmSync(runtimeGatewayDir, { recursive: true, force: true })
|
|
mkdirSync(runtimeGatewayDir, { recursive: true })
|
|
mkdirSync(pyinstallerWorkDir, { recursive: true })
|
|
|
|
const lightgbmBinaryArgs = addBinaryArg(
|
|
pythonPackageFile('lightgbm', platformLightgbmLibraryPath()),
|
|
platformLightgbmBundleDir(),
|
|
)
|
|
const macOpenMpBinaryArgs = process.platform === 'darwin'
|
|
? addBinaryArg(pythonPackageFile('sklearn', join('.dylibs', 'libomp.dylib')), '.')
|
|
: []
|
|
|
|
const args = [
|
|
'run',
|
|
'--extra',
|
|
'recommended',
|
|
'--extra',
|
|
'mcp',
|
|
'--extra',
|
|
'msg',
|
|
'--extra',
|
|
'matrix',
|
|
'--extra',
|
|
'document-extras',
|
|
'--with',
|
|
'pyinstaller',
|
|
'pyinstaller',
|
|
'--noconfirm',
|
|
'--clean',
|
|
'--onedir',
|
|
'--name',
|
|
'opensquilla-gateway',
|
|
'--distpath',
|
|
runtimeGatewayDir,
|
|
'--workpath',
|
|
pyinstallerWorkDir,
|
|
'--specpath',
|
|
pyinstallerWorkDir,
|
|
'--collect-all',
|
|
'opensquilla',
|
|
'--collect-all',
|
|
'sqlite_vec',
|
|
'--collect-binaries',
|
|
'sklearn',
|
|
'--copy-metadata',
|
|
'opensquilla',
|
|
'--copy-metadata',
|
|
'scikit-learn',
|
|
'--copy-metadata',
|
|
'lightgbm',
|
|
'--copy-metadata',
|
|
'yoyo-migrations',
|
|
'--hidden-import',
|
|
'joblib',
|
|
'--hidden-import',
|
|
'sklearn',
|
|
'--hidden-import',
|
|
'sklearn.feature_extraction.text',
|
|
'--hidden-import',
|
|
'sklearn.decomposition._truncated_svd',
|
|
'--hidden-import',
|
|
'sklearn.decomposition._pca',
|
|
'--hidden-import',
|
|
'sklearn.preprocessing._data',
|
|
'--hidden-import',
|
|
'lightgbm',
|
|
'--hidden-import',
|
|
'tokenizers',
|
|
'--hidden-import',
|
|
'tiktoken',
|
|
'--hidden-import',
|
|
'onnxruntime',
|
|
'--hidden-import',
|
|
'mcp',
|
|
'--hidden-import',
|
|
'yoyo.backends.core.sqlite3',
|
|
'--add-data',
|
|
`${join(repoRoot, 'migrations')}${addDataSeparator}opensquilla/_migrations`,
|
|
'--add-data',
|
|
`${controlUiDistDir}${addDataSeparator}opensquilla/gateway/static/dist`,
|
|
...lightgbmBinaryArgs,
|
|
...macOpenMpBinaryArgs,
|
|
entryPath,
|
|
]
|
|
|
|
const result = spawnSync('uv', args, {
|
|
cwd: repoRoot,
|
|
env: {
|
|
...process.env,
|
|
PYTHONUNBUFFERED: '1',
|
|
PYTHONUTF8: '1',
|
|
PYTHONIOENCODING: 'utf-8:replace',
|
|
},
|
|
stdio: 'inherit',
|
|
})
|
|
|
|
if (result.error) {
|
|
throw result.error
|
|
}
|
|
if (result.status !== 0) {
|
|
process.exit(result.status ?? 1)
|
|
}
|
|
|
|
patchMacLightgbmRuntime()
|