assistant/scripts/package-backend.mjs

172 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}