172 lines
8.7 KiB
JavaScript
172 lines
8.7 KiB
JavaScript
// 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;
|
||
}
|