#!/usr/bin/env node // OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap) // 用法: // node scripts/build-ota.mjs [--out ] [--sequence ] [--min-binary ] [--notes ] [--no-sign] [--key ] // 流程: // 1) 校验 dist/ 已构建 // 2) 生成 bundle.tar.gz (dist/ 内容) // 3) minisign 签名 (除非 --no-sign; 私钥优先读 MINISIGN_PRIVATE_KEY 环境变量) // 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...) // 5) 全部产物输出到 out 目录 (默认 ota-package/) // // minisign 安装: 见 README "OTA 升级" 章节 import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' import { fileURLToPath } from 'node:url' import { spawnSync } from 'node:child_process' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const ROOT = path.resolve(__dirname, '..') const args = process.argv.slice(2) function getArg(name, def) { const i = args.indexOf(name) return i >= 0 && args[i + 1] ? args[i + 1] : def } const OUT_DIR = path.resolve(getArg('--out', 'ota-package')) const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数 const MIN_BINARY = getArg('--min-binary', '') const NOTES = getArg('--notes', '') const NO_SIGN = args.includes('--no-sign') // 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径) const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key'))) const KEY_FROM_ENV = !!process.env.MINISIGN_PRIVATE_KEY // 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制) const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json') const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) const APP_VERSION = pkg.version || '0.0.0' const DIST = path.join(ROOT, 'dist') if (!fs.existsSync(path.join(DIST, 'index.html'))) { console.error('[build-ota] 错误: dist/index.html 不存在, 请先执行 npm run build') process.exit(1) } // 准备输出目录 fs.mkdirSync(OUT_DIR, { recursive: true }) // 1) 生成 bundle.tar.gz const BUNDLE_NAME = 'wardrobe-ota.tar.gz' const BUNDLE_PATH = path.join(OUT_DIR, BUNDLE_NAME) const r = spawnSync('tar', ['-czf', BUNDLE_PATH, '-C', DIST, '.'], { stdio: 'inherit' }) if (r.status !== 0) { console.error('[build-ota] tar 打包失败') process.exit(1) } const bundleStat = fs.statSync(BUNDLE_PATH) const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(BUNDLE_PATH)).digest('hex') // 2) 签名 (minisign) let signature = '' let pubkey = '' if (!NO_SIGN) { // CI 注入: 私钥走环境变量, 写到临时文件, 签名后立即删除 (避免落盘) let KEY_PATH_TMP = null let KEY_PATH_USE = KEY_PATH if (KEY_FROM_ENV) { KEY_PATH_TMP = path.join(OUT_DIR, `.hotswap.key.${process.pid}`) fs.writeFileSync(KEY_PATH_TMP, process.env.MINISIGN_PRIVATE_KEY, { mode: 0o600 }) KEY_PATH_USE = KEY_PATH_TMP } if (!fs.existsSync(KEY_PATH_USE)) { console.error('[build-ota] 私钥不存在:', KEY_PATH_USE) console.error(' 注入方式二选一:') console.error(' 1) CI: 设环境变量 MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)') console.error(' 2) 本机: 放 ' + KEY_PATH + ', 或用 --key ') console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key') process.exit(1) } // minisign -Sm -s 生成 detached signature const sigR = spawnSync('minisign', ['-Sm', BUNDLE_PATH, '-s', KEY_PATH_USE], { stdio: 'inherit' }) // 清理 CI 临时私钥 (无论成功失败都删) if (KEY_PATH_TMP) { try { fs.unlinkSync(KEY_PATH_TMP) } catch { /* ignore */ } KEY_PATH_TMP = null } if (sigR.status !== 0) { console.error('[build-ota] minisign 签名失败') process.exit(1) } const sigPath = BUNDLE_PATH + '.minisig' signature = fs.readFileSync(sigPath, 'utf8').trim() // 尝试同步 pubkey 到 tauri.conf.json (如果存在) const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub') if (fs.existsSync(PUB_PATH)) { pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim() const lines = pubkey.split(/\r?\n/) pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim() } } // 3) 写 latest.json const minBinary = MIN_BINARY || (() => { // 默认从 src-tauri/tauri.conf.json 读 version try { return JSON.parse(fs.readFileSync(TAURI_CONF, 'utf8')).version || APP_VERSION } catch { return APP_VERSION } })() const manifest = { version: APP_VERSION + '-ota', sequence: SEQUENCE, min_binary_version: minBinary, bundle: BUNDLE_NAME, bundle_size: bundleStat.size, bundle_sha256: bundleSha256, signature, notes: NOTES, pub_date: new Date().toISOString(), mandatory: false, // 内部用: 前端 web 模式比对 build_id build_id: 'seq-' + SEQUENCE, } fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2)) // 4) 提示 console.log('') console.log('=== OTA 包生成完成 ===') console.log('输出目录: ', OUT_DIR) console.log('版本: ', manifest.version) console.log('sequence: ', manifest.sequence) console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB') console.log('bundle sha256:', bundleSha256) console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)') if (pubkey) { console.log('') console.log('请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:') console.log(' ' + pubkey) } console.log('') console.log('下一步: 把这两个文件发布到 OTA 存储:') console.log(' ' + BUNDLE_NAME) console.log(' latest.json') console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。')