- 修复 web-admin 与 desktop-client 侧边栏点击无反应:el-menu 的 index 是路径,不能当路由名使用 - 修复 Web 产物目录含点号目录(如 .cache)时 res.sendFile 因 dotfiles:ignore 返回 500 - 后端生产模式:静态托管 Web 端 + SPA 回退、helmet、限流(通用/登录)、trust proxy、 /healthz 与 /readyz、请求耗时日志、生产环境变量强校验、首启动自动迁移与创建管理员、PID 文件 - Web 管理端 PWA:manifest + service worker + 图标,可在浏览器安装为应用 - 新增服务端 + Web 管理端 Windows 安装程序(内置 Node 运行时、启停与服务脚本、开机自启、卸载保留数据) - 新增 Docker/Compose/Nginx 部署样例与 docs/production.md - 新增 UI 回归测试:Web 端 Electron 驱动真实点击 7 项、客户端冒烟 11 项(含侧边栏点击与离线回退)
509 lines
18 KiB
JavaScript
509 lines
18 KiB
JavaScript
/**
|
||
* 服务端 + Web 管理端 生产打包脚本:pnpm dist:server
|
||
*
|
||
* 产出:
|
||
* release-server/classhelper-server/ 可直接运行的免安装目录
|
||
* release-server/班级小助手服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS)
|
||
*
|
||
* 打包策略(面向"双击即可用、目标机无需安装 Node"):
|
||
* 1. 内置 Node 运行时(复制本机 node.exe)—— 目标机无需安装 Node.js
|
||
* 2. 依赖用 npm 安装为真实目录(非 pnpm 软链),保证可整体拷贝到别的机器
|
||
* 3. 后端使用 tsc 构建产物(ESM),Web 管理端使用 Vite 构建产物
|
||
* 4. 首次启动自动执行迁移(AUTO_MIGRATE=true)+ 自动生成 JWT 密钥 + PID 文件
|
||
* 5. 用 electron-builder 自带的 NSIS(makensis)生成安装程序
|
||
*/
|
||
import { spawnSync } from 'node:child_process';
|
||
import crypto from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
const serverDir = path.join(root, 'packages', 'server');
|
||
const webDir = path.join(root, 'packages', 'web-admin');
|
||
const outRoot = path.join(root, 'release-server');
|
||
const staging = path.join(outRoot, 'classhelper-server');
|
||
|
||
const VERSION = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
||
const PRODUCT = '班级小助手服务端';
|
||
const PORT = '4000';
|
||
|
||
const log = (message) => console.log(`[dist:server] ${message}`);
|
||
|
||
const run = (command, args, options = {}) => {
|
||
// Windows 下 .cmd 必须通过 cmd.exe 启动(Node 20+ 不再允许直接 spawn .cmd)
|
||
const needsShell = process.platform === 'win32' && command.toLowerCase().endsWith('.cmd');
|
||
const result = spawnSync(command, args, { stdio: 'inherit', shell: needsShell, ...options });
|
||
if (result.error) {
|
||
throw new Error(`命令启动失败:${command} ${args.join(' ')} -> ${result.error.message}`);
|
||
}
|
||
if (result.status !== 0) {
|
||
throw new Error(`命令失败(${result.status}):${command} ${args.join(' ')}`);
|
||
}
|
||
};
|
||
|
||
/* ------------------------------------------------------------ 1. 前置构建 */
|
||
|
||
function ensureBuilds() {
|
||
const serverEntry = path.join(serverDir, 'dist', 'index.js');
|
||
const webEntry = path.join(webDir, 'dist', 'index.html');
|
||
const missing = [];
|
||
if (!fs.existsSync(serverEntry))
|
||
missing.push('packages/server/dist(请先 pnpm --filter @classhelper/server build)');
|
||
if (!fs.existsSync(webEntry))
|
||
missing.push('packages/web-admin/dist(请先 pnpm --filter @classhelper/web-admin build)');
|
||
if (missing.length > 0) {
|
||
throw new Error(`缺少构建产物:\n - ${missing.join('\n - ')}`);
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 2. 依赖安装 */
|
||
|
||
function serverProdDependencies() {
|
||
const pkg = JSON.parse(fs.readFileSync(path.join(serverDir, 'package.json'), 'utf8'));
|
||
const deps = { ...pkg.dependencies };
|
||
// 工作区内部包用拷贝的方式提供,不走 npm
|
||
delete deps['@classhelper/shared'];
|
||
return deps;
|
||
}
|
||
|
||
/** 运行时依赖清单(同时供安装包与 Docker 运行时镜像使用,单一事实来源) */
|
||
function writeRuntimeManifest() {
|
||
const manifest = {
|
||
name: 'classhelper-server-runtime',
|
||
version: VERSION,
|
||
private: true,
|
||
type: 'module',
|
||
description: '班级小助手服务端运行时(安装包 / 容器内置)',
|
||
dependencies: serverProdDependencies(),
|
||
};
|
||
fs.writeFileSync(path.join(staging, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||
|
||
const deployDir = path.join(root, 'deploy');
|
||
fs.mkdirSync(deployDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(deployDir, 'package.runtime.json'),
|
||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||
'utf8',
|
||
);
|
||
return manifest;
|
||
}
|
||
|
||
function installDependencies() {
|
||
writeRuntimeManifest();
|
||
|
||
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||
log('安装生产依赖(npm install --omit=dev,真实目录,便于整体拷贝)...');
|
||
run(npmCmd, ['install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], {
|
||
cwd: staging,
|
||
env: {
|
||
...process.env,
|
||
npm_config_cache: path.join(root, '.cache', 'npm-cache'),
|
||
NODE_OPTIONS: process.env.NODE_OPTIONS ?? '--use-system-ca',
|
||
},
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 3. 组装运行时 */
|
||
|
||
function copyRecursive(from, to) {
|
||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||
fs.cpSync(from, to, { recursive: true, dereference: true });
|
||
}
|
||
|
||
function buildRuntime() {
|
||
// 后端代码:保留 dist 层级,保证代码里 serverRoot 的计算(../..)与开发环境一致
|
||
copyRecursive(path.join(serverDir, 'dist'), path.join(staging, 'server', 'dist'));
|
||
// 迁移文件(首次启动 AUTO_MIGRATE 使用)
|
||
copyRecursive(
|
||
path.join(serverDir, 'prisma', 'migrations'),
|
||
path.join(staging, 'server', 'prisma', 'migrations'),
|
||
);
|
||
// 工作区共享包(tsc 产物)
|
||
const sharedTarget = path.join(staging, 'node_modules', '@classhelper', 'shared');
|
||
fs.mkdirSync(sharedTarget, { recursive: true });
|
||
copyRecursive(path.join(root, 'packages', 'shared', 'dist'), path.join(sharedTarget, 'dist'));
|
||
fs.writeFileSync(
|
||
path.join(sharedTarget, 'package.json'),
|
||
JSON.stringify(
|
||
{
|
||
name: '@classhelper/shared',
|
||
version: VERSION,
|
||
private: true,
|
||
type: 'module',
|
||
main: './dist/index.js',
|
||
types: './dist/index.d.ts',
|
||
exports: { '.': { types: './dist/index.d.ts', default: './dist/index.js' } },
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
'utf8',
|
||
);
|
||
|
||
// Web 管理端
|
||
copyRecursive(path.join(webDir, 'dist'), path.join(staging, 'web'));
|
||
|
||
// Node 运行时
|
||
const nodeExe = process.execPath;
|
||
copyRecursive(nodeExe, path.join(staging, 'node.exe'));
|
||
log(`内置 Node 运行时:${nodeExe}`);
|
||
|
||
// 数据目录:强制清空,避免把打包机上测试用的数据库带进安装包
|
||
fs.rmSync(path.join(staging, 'data'), { recursive: true, force: true });
|
||
fs.mkdirSync(path.join(staging, 'data'), { recursive: true });
|
||
fs.mkdirSync(path.join(staging, 'logs'), { recursive: true });
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 4. 配置与脚本 */
|
||
|
||
function writeEnvironmentFile() {
|
||
const jwtSecret = crypto.randomBytes(48).toString('hex');
|
||
const content = `# 班级小助手服务端(安装程序生成,请妥善保管)
|
||
# 修改后需重启服务生效(开始菜单 → 班级小助手服务端 → 重启服务)
|
||
|
||
NODE_ENV=production
|
||
HOST=0.0.0.0
|
||
PORT=${PORT}
|
||
|
||
# 数据库:SQLite 文件位于安装目录的 data 子目录
|
||
DATABASE_PROVIDER=sqlite
|
||
DATABASE_URL="file:../data/classhelper.db"
|
||
# 首次启动时自动建表(数据库为空时执行随包的迁移 SQL)
|
||
AUTO_MIGRATE=true
|
||
|
||
# 登录令牌签名密钥(首次安装随机生成,请勿泄露/更换,更换后所有用户需重新登录)
|
||
JWT_SECRET=${jwtSecret}
|
||
JWT_EXPIRES_IN=7d
|
||
|
||
# 允许跨域来源:* 表示全部允许;若通过域名访问建议改成具体来源,例如 https://class.example.com
|
||
CORS_ORIGIN=*
|
||
|
||
# 反代场景请设为 1,保证限流按真实客户端 IP 统计
|
||
TRUST_PROXY=0
|
||
# 安全与运维
|
||
RATE_LIMIT_ENABLED=true
|
||
STARTUP_DB_CHECK=true
|
||
PID_FILE=../data/server.pid
|
||
LOG_LEVEL=info
|
||
|
||
# 可选项
|
||
BCRYPT_ROUNDS=10
|
||
DEFAULT_STUDENT_PASSWORD=123456
|
||
TERM_START_DATE=
|
||
`;
|
||
fs.writeFileSync(path.join(staging, '.env'), content, 'utf8');
|
||
}
|
||
|
||
function writeRuntimeScripts() {
|
||
// 注意:.cmd 内容保持 ASCII,避免 cmd.exe 按 OEM 代码页(GBK)解析 UTF-8 中文出错;
|
||
// 中文说明放在 README.txt 与安装向导里。
|
||
// 启动采用 PowerShell Start-Process 分离进程 + 输出重定向到日志文件,
|
||
// 避免 `start` 在管道/非交互环境下让子进程继承句柄而卡住调用方。
|
||
const startCmd = `@echo off
|
||
setlocal
|
||
cd /d "%~dp0"
|
||
|
||
set "PID="
|
||
if exist "data\\server.pid" set /p PID=<"data\\server.pid"
|
||
|
||
if defined PID (
|
||
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
|
||
if not errorlevel 1 (
|
||
echo [ClassHelper] Server is already running ^(PID %PID%^).
|
||
echo [ClassHelper] Admin UI: http://127.0.0.1:${PORT}/
|
||
exit /b 0
|
||
)
|
||
)
|
||
|
||
if not exist "logs" mkdir "logs"
|
||
echo [ClassHelper] Starting server...
|
||
|
||
powershell -NoLogo -NoProfile -NonInteractive -Command ^
|
||
"Start-Process -FilePath '%~dp0node.exe' -ArgumentList '%~dp0server\\dist\\index.js' -WorkingDirectory '%~dp0' -WindowStyle Hidden -RedirectStandardOutput '%~dp0logs\\server.log' -RedirectStandardError '%~dp0logs\\server.error.log'"
|
||
|
||
echo [ClassHelper] Waiting for service to become ready...
|
||
for /l %%i in (1,1,30) do (
|
||
powershell -NoLogo -NoProfile -NonInteractive -Command "try { $null = Invoke-WebRequest -Uri 'http://127.0.0.1:${PORT}/healthz' -UseBasicParsing -TimeoutSec 2; exit 0 } catch { exit 1 }" >nul 2>&1
|
||
if not errorlevel 1 (
|
||
echo [ClassHelper] Ready: http://127.0.0.1:${PORT}/
|
||
exit /b 0
|
||
)
|
||
ping -n 2 127.0.0.1 >nul
|
||
)
|
||
|
||
echo [ClassHelper] Start timed out. Check logs\\server.error.log for details.
|
||
exit /b 1
|
||
`;
|
||
|
||
const stopCmd = `@echo off
|
||
setlocal
|
||
cd /d "%~dp0"
|
||
|
||
if not exist "data\\server.pid" (
|
||
echo [ClassHelper] No PID file found - server is not running.
|
||
exit /b 0
|
||
)
|
||
|
||
set /p PID=<"data\\server.pid"
|
||
echo [ClassHelper] Stopping server ^(PID %PID%^)...
|
||
powershell -NoLogo -NoProfile -NonInteractive -Command "try { Stop-Process -Id %PID% -Force -ErrorAction Stop; exit 0 } catch { exit 1 }" >nul 2>&1
|
||
if errorlevel 1 taskkill /PID %PID% /T /F >nul 2>&1
|
||
del /q "data\\server.pid" >nul 2>&1
|
||
echo [ClassHelper] Stopped.
|
||
`;
|
||
|
||
const restartCmd = `@echo off
|
||
call "%~dp0stop.cmd"
|
||
ping -n 2 127.0.0.1 >nul
|
||
call "%~dp0start.cmd"
|
||
`;
|
||
|
||
const statusCmd = `@echo off
|
||
setlocal enabledelayedexpansion
|
||
cd /d "%~dp0"
|
||
|
||
if exist "data\\server.pid" (
|
||
set /p PID=<"data\\server.pid"
|
||
powershell -NoLogo -NoProfile -NonInteractive -Command "if (Get-Process -Id !PID! -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }" >nul 2>&1
|
||
if not errorlevel 1 (
|
||
echo [ClassHelper] Running ^(PID !PID!^)
|
||
powershell -NoLogo -NoProfile -NonInteractive -Command "try { $r = Invoke-WebRequest -Uri 'http://127.0.0.1:${PORT}/api/health' -UseBasicParsing -TimeoutSec 3; Write-Host ' HTTP:' $r.StatusCode } catch { Write-Host ' HTTP: unreachable' }"
|
||
echo [ClassHelper] Admin UI: http://127.0.0.1:${PORT}/
|
||
exit /b 0
|
||
)
|
||
)
|
||
echo [ClassHelper] Not running.
|
||
exit /b 1
|
||
`;
|
||
|
||
const serviceCmd = `@echo off
|
||
setlocal
|
||
cd /d "%~dp0"
|
||
|
||
net session >nul 2>&1
|
||
if errorlevel 1 (
|
||
echo [ClassHelper] Administrator privileges are required. Right-click and run as administrator.
|
||
pause
|
||
exit /b 1
|
||
)
|
||
|
||
echo [ClassHelper] Registering scheduled task (auto start at boot)...
|
||
schtasks /Create /TN "ClassHelperServer" /TR "\\"%~dp0node.exe\\" \\"%~dp0server\\dist\\index.js\\"" /SC ONSTART /RL HIGHEST /F
|
||
schtasks /Run /TN "ClassHelperServer"
|
||
echo [ClassHelper] Done.
|
||
echo start : schtasks /Run /TN ClassHelperServer
|
||
echo stop : schtasks /End /TN ClassHelperServer
|
||
echo remove: schtasks /Delete /TN ClassHelperServer /F
|
||
pause
|
||
`;
|
||
|
||
const unserviceCmd = `@echo off
|
||
setlocal
|
||
net session >nul 2>&1
|
||
if errorlevel 1 (
|
||
echo [ClassHelper] Administrator privileges are required.
|
||
pause
|
||
exit /b 1
|
||
)
|
||
schtasks /End /TN "ClassHelperServer" >nul 2>&1
|
||
schtasks /Delete /TN "ClassHelperServer" /F
|
||
echo [ClassHelper] Scheduled task removed.
|
||
pause
|
||
`;
|
||
|
||
const files = {
|
||
'start.cmd': startCmd,
|
||
'stop.cmd': stopCmd,
|
||
'restart.cmd': restartCmd,
|
||
'status.cmd': statusCmd,
|
||
'install-service.cmd': serviceCmd,
|
||
'uninstall-service.cmd': unserviceCmd,
|
||
};
|
||
|
||
for (const [name, content] of Object.entries(files)) {
|
||
fs.writeFileSync(path.join(staging, name), content.replace(/\r?\n/g, '\r\n'), 'utf8');
|
||
}
|
||
}
|
||
|
||
function writeReadme() {
|
||
const content = `${PRODUCT} v${VERSION}
|
||
========================================
|
||
|
||
一、启动与访问
|
||
1. 双击「start.cmd」(或开始菜单 → 班级小助手服务端 → 启动服务)
|
||
2. 浏览器打开 http://127.0.0.1:${PORT}/ 即可进入 Web 管理端
|
||
3. 安装程序已把本服务加入当前用户的开机自启项,重启电脑后无需手动启动
|
||
|
||
二、默认账号(首次安装后请立即修改密码)
|
||
管理员 admin / admin123
|
||
教师 teacher1 / teacher123、teacher2 / teacher123
|
||
学生 student01 ~ student15 / student123(桌面客户端使用)
|
||
|
||
三、目录说明
|
||
server\\ 后端程序(server\\dist\\index.js 为入口)
|
||
web\\ Web 管理端(由后端直接托管)
|
||
data\\ 数据库文件与 PID 文件(备份时复制此目录即可)
|
||
logs\\ 预留日志目录
|
||
.env 全部配置项(端口、数据库、密钥、跨域等)
|
||
node.exe 内置 Node 运行时(无需在系统中安装 Node.js)
|
||
|
||
四、常用操作
|
||
start.cmd 启动服务并等待就绪
|
||
stop.cmd 停止服务
|
||
restart.cmd 重启服务
|
||
status.cmd 查看运行状态
|
||
install-service.cmd 注册为开机启动的计划任务(需管理员身份运行)
|
||
uninstall-service.cmd 移除计划任务(需管理员身份运行)
|
||
|
||
五、升级
|
||
1. 先执行 stop.cmd 停止服务
|
||
2. 备份 data 目录
|
||
3. 运行新版本安装程序覆盖安装(.env 会被保留,不会重置密钥与数据库)
|
||
4. 执行 start.cmd
|
||
|
||
六、安全建议
|
||
- 修改 admin / teacher 的默认密码
|
||
- 如需通过公网访问,请在前面加 Nginx/Caddy 反向代理并启用 HTTPS(见 docs/production.md)
|
||
- 定期备份 data 目录;更换 JWT_SECRET 会导致所有用户需要重新登录
|
||
`;
|
||
fs.writeFileSync(path.join(staging, 'README.txt'), content.replace(/\r?\n/g, '\r\n'), 'utf8');
|
||
|
||
// 许可证/说明文件(安装向导的许可页读取它)
|
||
const license = `${PRODUCT} v${VERSION} 使用许可与说明
|
||
|
||
1. 用途
|
||
本程序用于班级信息管理(班级、课表、作业、通知、成绩),由后端服务与 Web 管理端组成。
|
||
|
||
2. 运行方式
|
||
安装后将在本机启动一个 HTTP 服务(默认端口 ${PORT}),并把 Web 管理端一并托管。
|
||
该服务默认监听 0.0.0.0,同一局域网内的设备可通过本机 IP 访问;
|
||
若需公网访问,请务必在反向代理上启用 HTTPS 并修改默认密码。
|
||
|
||
3. 数据与备份
|
||
全部数据保存在安装目录的 data 目录(SQLite 文件)。定期复制该目录即为完整备份。
|
||
|
||
4. 卸载
|
||
卸载程序会停止服务并删除程序文件,但会把 data 目录重命名为 data.backup 保留,
|
||
如需彻底删除请手动删除该目录。
|
||
|
||
5. 免责
|
||
本程序按"现状"提供,请在使用前自行评估并做好数据备份。
|
||
`;
|
||
fs.writeFileSync(path.join(staging, 'LICENSE.txt'), license.replace(/\r?\n/g, '\r\n'), 'utf8');
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 5. NSIS 安装程序 */
|
||
|
||
function findMakensis() {
|
||
const cacheRoot = path.join(root, '.cache', 'electron-builder');
|
||
const candidates = [];
|
||
const walk = (dir, depth = 0) => {
|
||
if (depth > 3 || !fs.existsSync(dir)) return;
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const full = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) walk(full, depth + 1);
|
||
else if (entry.name.toLowerCase() === 'makensis.exe') candidates.push(full);
|
||
}
|
||
};
|
||
walk(cacheRoot);
|
||
return candidates[0] ?? null;
|
||
}
|
||
|
||
function buildInstaller() {
|
||
const makensis = findMakensis();
|
||
if (!makensis) {
|
||
log('未找到 makensis(NSIS),跳过安装程序生成。');
|
||
log('可执行一次 pnpm dist:win 让 electron-builder 下载 NSIS,或手动安装 NSIS 后重试。');
|
||
return null;
|
||
}
|
||
|
||
const outFile = path.join(outRoot, `${PRODUCT}-${VERSION}-x64-setup.exe`);
|
||
const templatePath = path.join(root, 'scripts', 'nsis', 'server-installer.nsi');
|
||
const script = fs
|
||
.readFileSync(templatePath, 'utf8')
|
||
.replaceAll('@PRODUCT@', PRODUCT)
|
||
.replaceAll('@VERSION@', VERSION)
|
||
.replaceAll('@STAGING@', staging)
|
||
.replaceAll('@OUTFILE@', outFile)
|
||
.replaceAll('@ICON@', path.join(root, 'build', 'icon.ico'))
|
||
.replaceAll('@PORT@', PORT);
|
||
|
||
const generatedScript = path.join(outRoot, 'server-installer.generated.nsi');
|
||
// NSIS 通过 BOM 识别 UTF-8,脚本含中文必须写入 BOM,否则报 "Bad text encoding"
|
||
fs.writeFileSync(generatedScript, `\uFEFF${script}`, 'utf8');
|
||
|
||
// 防御:若上一次的产物仍被占用(例如同时还开着安装程序),先尝试删除并给出明确提示
|
||
if (fs.existsSync(outFile)) {
|
||
try {
|
||
fs.rmSync(outFile, { force: true });
|
||
} catch {
|
||
throw new Error(
|
||
`无法覆盖已有安装程序(可能仍在运行/被杀毒软件占用):${outFile}\n请关闭该安装程序后重试。`,
|
||
);
|
||
}
|
||
}
|
||
|
||
log(`生成安装程序:${path.relative(root, outFile)}`);
|
||
run(makensis, ['/V2', generatedScript], { cwd: outRoot });
|
||
return outFile;
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 主流程 */
|
||
|
||
function main() {
|
||
log(`版本 ${VERSION}`);
|
||
ensureBuilds();
|
||
|
||
// 迭代打包时可加 --reuse-deps 复用已安装的 node_modules,跳过 2 分钟的 npm install
|
||
const reuseDeps = process.argv.includes('--reuse-deps');
|
||
const hasDeps = fs.existsSync(path.join(staging, 'node_modules'));
|
||
|
||
if (!(reuseDeps && hasDeps)) {
|
||
fs.rmSync(staging, { recursive: true, force: true });
|
||
} else {
|
||
log('复用已安装的 node_modules(--reuse-deps)');
|
||
// 只保留依赖安装结果,其余目录重新生成,避免残留上一次打包的文件
|
||
for (const dir of ['server', 'web', 'logs']) {
|
||
fs.rmSync(path.join(staging, dir), { recursive: true, force: true });
|
||
}
|
||
}
|
||
fs.mkdirSync(staging, { recursive: true });
|
||
|
||
log('组装运行时...');
|
||
writeRuntimeManifest();
|
||
if (reuseDeps && hasDeps) {
|
||
log('跳过依赖安装');
|
||
} else {
|
||
installDependencies();
|
||
}
|
||
buildRuntime();
|
||
writeEnvironmentFile();
|
||
writeRuntimeScripts();
|
||
writeReadme();
|
||
|
||
const installer = buildInstaller();
|
||
|
||
const sizeMb = (target) => {
|
||
let total = 0;
|
||
const walk = (dir) => {
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const full = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) walk(full);
|
||
else total += fs.statSync(full).size;
|
||
}
|
||
};
|
||
walk(target);
|
||
return Math.round(total / 1024 / 1024);
|
||
};
|
||
|
||
log(`免安装目录:${path.relative(root, staging)}(${sizeMb(staging)} MB)`);
|
||
if (installer)
|
||
log(
|
||
`安装程序:${path.relative(root, installer)}(${Math.round(fs.statSync(installer).size / 1024 / 1024)} MB)`,
|
||
);
|
||
log('完成。');
|
||
}
|
||
|
||
main();
|