// npm run backend:package —— 用 .venv 里的 PyInstaller 把 FastAPI 后端冻结成 onedir // // 产物:native/release/school-assistant-backend/(electron-builder 以 extraResources 打进安装包的 resources/backend) // 验收:1) exe --selfcheck 导入全部依赖并核对 browser-use 版本 // 2) 本机 PostgreSQL(.env 的 PG_*)可达时真的起一次服务:等 /api/health,再 POST /api/shutdown // 结果写 native/build/package-smoke.json import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { createConnection } from 'node:net'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const win = process.platform === 'win32'; const python = path.join(root, '.venv', win ? 'Scripts/python.exe' : 'bin/python'); const build = path.join(root, 'native/build'); const release = path.join(root, 'native/release'); const name = 'school-assistant-backend'; const executable = path.join(release, name, win ? `${name}.exe` : name); const SMOKE_PORT = 8798; function run(command, args, env = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: root, stdio: 'inherit', windowsHide: true, env: { ...process.env, PYTHONUTF8: '1', ...env } }); child.once('error', reject); child.once('exit', code => (code === 0 ? resolve() : reject(new Error(`${command} 退出码 ${code}`)))); }); } function capture(command, args, env, timeoutMs) { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd: root, windowsHide: true, env: { ...process.env, PYTHONUTF8: '1', ...env }, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; const timer = setTimeout(() => { child.kill(); reject(new Error(`${path.basename(command)} 超过 ${timeoutMs / 1000}s 未结束`)); }, timeoutMs); child.stdout.setEncoding('utf8').on('data', chunk => { stdout += chunk; }); child.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk; }); child.once('error', error => { clearTimeout(timer); reject(error); }); child.once('exit', code => { clearTimeout(timer); resolve({ code, stdout, stderr }); }); }); } async function sizeOf(file) { const details = await lstat(file); if (!details.isDirectory()) return details.size; const sizes = await Promise.all((await readdir(file)).map(child => sizeOf(path.join(file, child)))); return sizes.reduce((total, size) => total + size, 0); } function readEnv() { const values = {}; const file = path.join(root, '.env'); if (!existsSync(file)) return values; return readFile(file, 'utf8').then(text => { for (const line of text.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eq = trimmed.indexOf('='); if (eq > 0) values[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); } return values; }); } function portOpen(host, port) { return new Promise(resolve => { const socket = createConnection({ host, port }); const done = ok => { socket.destroy(); resolve(ok); }; socket.setTimeout(800, () => done(false)); socket.once('connect', () => done(true)); socket.once('error', () => done(false)); }); } async function httpJson(method, url, timeoutMs = 2000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { method, signal: controller.signal }); return { status: response.status, json: await response.json().catch(() => ({})) }; } finally { clearTimeout(timer); } } /** 真起一次冻结后端:连本机 PG、建表、监听 8798,成功后让它自己退出。 */ async function bootSmoke(home, env) { const child = spawn(executable, [], { cwd: home, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PYTHONUTF8: '1', ...env, API_PORT: String(SMOKE_PORT) }, }); let output = ''; child.stdout.setEncoding('utf8').on('data', chunk => { output += chunk; }); child.stderr.setEncoding('utf8').on('data', chunk => { output += chunk; }); const startedAt = Date.now(); try { let health = null; while (Date.now() - startedAt < 60000 && child.exitCode === null) { try { const res = await httpJson('GET', `http://127.0.0.1:${SMOKE_PORT}/api/health`); if (res.status === 200) { health = res.json; break; } } catch { /* not yet */ } await new Promise(resolve => setTimeout(resolve, 500)); } if (!health) throw new Error(`冻结后端 60s 内没就绪。输出:${output.slice(-1200)}`); const meta = await httpJson('GET', `http://127.0.0.1:${SMOKE_PORT}/api/meta`); await httpJson('POST', `http://127.0.0.1:${SMOKE_PORT}/api/shutdown`).catch(() => undefined); await new Promise(resolve => setTimeout(resolve, 1500)); return { readyMs: Date.now() - startedAt, pid: health.pid, school: meta.json.school_id, db: meta.json.db }; } finally { if (child.exitCode === null) child.kill(); } } try { if (!existsSync(python)) throw new Error('先运行 npm run backend:setup'); await mkdir(build, { recursive: true }); await run(python, ['-m', 'PyInstaller', '--noconfirm', '--clean', '--onedir', '--name', name, '--distpath', release, '--workpath', path.join(build, 'pyinstaller'), '--specpath', build, '--paths', root, // 冻结后 bootloader 不读 PYTHONUTF8,用 -X utf8 让解释器进 UTF-8 模式(否则中文 Windows 上 stdout / 默认 open() 都是 GBK) '--python-option', 'X utf8', '--collect-submodules', 'backend', '--add-data', `${path.join(root, 'backend/db/schema.sql')}${path.delimiter}backend/db`, '--collect-all', 'browser_use', '--collect-all', 'bubus', '--collect-all', 'cdp_use', '--recursive-copy-metadata', 'browser-use', '--collect-submodules', 'uvicorn', '--collect-submodules', 'psycopg', '--collect-all', 'psycopg_binary', path.join(root, 'backend/main.py')], { ANONYMIZED_TELEMETRY: 'false', BROWSER_USE_CLOUD_SYNC: 'false', BROWSER_USE_VERSION_CHECK: 'false', DO_NOT_TRACK: '1', }); const home = await mkdtemp(path.join(tmpdir(), 'school-assistant-package-')); const envValues = await readEnv(); const env = { ASSISTANT_HOME: home, KNOWLEDGE_DIR: path.join(root, 'knowledge'), ANONYMIZED_TELEMETRY: 'false', BROWSER_USE_CLOUD_SYNC: 'false', BROWSER_USE_VERSION_CHECK: 'false', ...Object.fromEntries(Object.entries(envValues).filter(([key]) => key.startsWith('PG_') || key === 'SCHOOL_ID' || key === 'ACCOUNT_ENC_KEY')), }; let result; try { const check = await capture(executable, ['--selfcheck'], env, 90000); const line = check.stdout.trim().split(/\r?\n/).pop() ?? ''; let report; try { report = JSON.parse(line); } catch { throw new Error(`selfcheck 输出不是 JSON(退出码 ${check.code}):${(check.stderr || check.stdout).slice(-1500)}`); } if (check.code !== 0 || !report.ok) throw new Error(`selfcheck 失败:${check.stderr.slice(-1500)}`); if (report.versions['browser-use'] !== '0.13.10') throw new Error(`冻结产物里的 browser-use 是 ${report.versions['browser-use']},应为 0.13.10`); if (!report.schema_sql) throw new Error('冻结产物缺 backend/db/schema.sql'); if (report.knowledge_files.length === 0) throw new Error('selfcheck 没看到 knowledge/*.yaml'); const pgHost = envValues.PG_HOST || '127.0.0.1'; const pgPort = parseInt(envValues.PG_PORT || '15433', 10); const boot = (await portOpen(pgHost, pgPort)) ? await bootSmoke(home, env) : { skipped: `PostgreSQL ${pgHost}:${pgPort} 不可达,跳过真实启动` }; result = { status: 'passed', platform: process.platform, arch: process.arch, executable: path.relative(root, executable), sizeBytes: await sizeOf(path.dirname(executable)), selfcheck: report, boot, builtAt: new Date().toISOString(), }; } finally { await rm(home, { recursive: true, force: true }); } await writeFile(path.join(build, 'package-smoke.json'), `${JSON.stringify(result, null, 2)}\n`); console.log(`[backend:package] 通过:${(result.sizeBytes / 1024 / 1024).toFixed(1)} MiB,browser-use ${result.selfcheck.versions['browser-use']},启动:${JSON.stringify(result.boot)}`); } catch (error) { await mkdir(build, { recursive: true }); await writeFile(path.join(build, 'package-smoke.json'), `${JSON.stringify({ status: 'failed', message: error.message, checkedAt: new Date().toISOString() }, null, 2)}\n`); console.error(`[backend:package] 失败:${error.message}`); process.exitCode = 1; }