#!/usr/bin/env node 'use strict' /** * HBuilderX CLI 构建包装器。 * * 背景:本项目是 HBuilderX 工程,Vue3 编译所需的 @dcloudio/* 工具链只随 * HBuilderX 分发,不在项目 package.json 里。直接在项目目录跑 `npm install` * 会装入 Vue2 时代的旧包,且每次 install 都会清掉手工建的联接。 * * 因此本脚本在构建前自动完成三件事(幂等,可反复跑): * 1. 把 HBuilderX 自带的工具链目录联接到项目 node_modules/ * 2. 设置 HX_APP_ROOT / UNI_INPUT_DIR / UNI_OUTPUT_DIR,让 uni CLI * 走 HBuilderX 内置模块解析路径(缺 HX_APP_ROOT 时会退化成 H5 空壳产物) * 3. 用 HBuilderX 自带的 node 执行 `uni build -p ` * * 不用打开 HBuilderX GUI,也不需要联网。 * * 用法: * node tools/build.js -p h5 * node tools/build.js -p mp-weixin * node tools/build.js -p h5 --report # 额外打印产物体积明细 * node tools/build.js --link-only # 只建联接,不构建 * node tools/build.js --doctor # 只做环境体检 * * 环境变量: * HBUILDERX_HOME HBuilderX 安装目录,默认 C:/Program Files/HBuilderX/HBuilderX */ const fs = require('fs') const path = require('path') const { spawnSync } = require('child_process') const ROOT = path.resolve(__dirname, '..') const HBX = process.env.HBUILDERX_HOME || 'C:/Program Files/HBuilderX/HBuilderX' const HBX_TC = path.join(HBX, 'plugins', 'uniapp-cli-vite', 'node_modules') const HBX_SASS = path.join(HBX, 'plugins', 'compile-dart-sass', 'node_modules') const UNI_JS = path.join(HBX_TC, '@dcloudio', 'vite-plugin-uni', 'bin', 'uni.js') const NM = path.join(ROOT, 'node_modules') // HBuilderX 自带的 node。系统 node 太新时 vite 的 config 缓存格式不兼容 // (failed to load config / Invalid or incompatible cached data), // 固定用 HBuilderX 的 node 最稳。 const HBX_NODE_CANDIDATES = [ path.join(HBX, 'plugins', 'node', 'node.exe'), path.join(HBX, 'plugins', 'node18', 'node.exe') ] const PLATFORM_OUTPUT = { h5: 'unpackage/dist/build/web', 'mp-weixin': 'unpackage/dist/build/mp-weixin', 'mp-alipay': 'unpackage/dist/build/mp-alipay', app: 'unpackage/dist/build/app-plus' } // ── 联接管理 ──────────────────────────────────────────────── function isLink(target) { try { return fs.lstatSync(target).isSymbolicLink() || fs.lstatSync(target).isDirectory() } catch { return false } } /** * 建立 junction。Node 没有跨平台的 mklink API,Windows 上退到 cmd。 * 目录联接不需要管理员权限(符号链接才需要)。 */ function junction(linkPath, targetPath) { const r = spawnSync('cmd', ['/c', 'mklink', '/J', linkPath, targetPath], { encoding: 'utf8', windowsHide: true }) return r.status === 0 } function linkAll() { if (!fs.existsSync(HBX_TC)) { console.error(`✗ 找不到 HBuilderX 工具链:${HBX_TC}`) console.error(' 请设置 HBUILDERX_HOME 指向 HBuilderX 安装目录。') return { ok: false, created: 0 } } if (!fs.existsSync(UNI_JS)) { console.error(`✗ 找不到 uni CLI:${UNI_JS}`) return { ok: false, created: 0 } } fs.mkdirSync(NM, { recursive: true }) let created = 0 // 1) scoped 包:@dcloudio/*、@vue/* 等 const scopes = [] for (const entry of fs.readdirSync(HBX_TC, { withFileTypes: true })) { if (entry.isDirectory() && entry.name.startsWith('@')) scopes.push(entry.name) } for (const scope of scopes) { const scopeDir = path.join(HBX_TC, scope) fs.mkdirSync(path.join(NM, scope), { recursive: true }) for (const pkg of fs.readdirSync(scopeDir)) { const src = path.join(scopeDir, pkg) if (!fs.statSync(src).isDirectory()) continue const dest = path.join(NM, scope, pkg) if (fs.existsSync(dest)) continue if (junction(dest, src)) created++ } } // 2) vue 必须指向 uni-app 打过补丁的运行时。 // 官方 npm vue 3.x 不导出 isInSSRComponentSetup,而 @dcloudio/uni-app // 从 'vue' 导入它 —— 用原版 vue 构建会直接失败在 rollup 解析阶段。 const patchedVue = path.join(HBX_TC, '@dcloudio', 'uni-h5-vue') const vueDest = path.join(NM, 'vue') if (fs.existsSync(patchedVue) && !fs.existsSync(vueDest)) { if (junction(vueDest, patchedVue)) created++ } // 3) 顶层工具包(vite / sass / rollup / esbuild ...)。 // sass 在另一个插件目录里。 for (const base of [HBX_TC, HBX_SASS]) { if (!fs.existsSync(base)) continue for (const pkg of fs.readdirSync(base)) { if (pkg.startsWith('@') || pkg.startsWith('.')) continue const src = path.join(base, pkg) let stat try { stat = fs.statSync(src) } catch { continue } if (!stat.isDirectory()) continue // 有些包用符号链接指向同目录其它包,跳过避免自指 const dest = path.join(NM, pkg) if (fs.existsSync(dest)) continue if (junction(dest, src)) created++ } } return { ok: true, created } } // ── 构建 ──────────────────────────────────────────────────── function dirSize(dir) { let total = 0 const stack = [dir] while (stack.length) { const d = stack.pop() let entries try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { continue } for (const e of entries) { const p = path.join(d, e.name) if (e.isDirectory()) stack.push(p) else { try { total += fs.statSync(p).size } catch {} } } } return total } function countFiles(dir, ext) { let n = 0 const stack = [dir] while (stack.length) { const d = stack.pop() let entries try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { continue } for (const e of entries) { if (e.isDirectory()) stack.push(path.join(d, e.name)) else if (e.name.endsWith(ext)) n++ } } return n } function report(outputDir) { if (!fs.existsSync(outputDir)) { console.log(' (无产物目录)') return } const fmt = (n) => (n / 1024).toFixed(1) + ' KB' console.log(` 产物目录:${path.relative(ROOT, outputDir).replace(/\\/g, '/')}`) const entries = fs.readdirSync(outputDir, { withFileTypes: true }) .map((e) => { const p = path.join(outputDir, e.name) return { name: e.name + (e.isDirectory() ? '/' : ''), size: e.isDirectory() ? dirSize(p) : fs.statSync(p).size } }) .sort((a, b) => b.size - a.size) for (const e of entries.slice(0, 10)) { console.log(` ${e.name.padEnd(24)} ${fmt(e.size)}`) } const total = entries.reduce((s, e) => s + e.size, 0) console.log(` ${'合计'.padEnd(22)} ${fmt(total)}`) } function main() { const argv = process.argv.slice(2) const linkOnly = argv.includes('--link-only') const doctorOnly = argv.includes('--doctor') const wantReport = argv.includes('--report') const pIdx = argv.indexOf('-p') const platform = pIdx >= 0 ? argv[pIdx + 1] : 'h5' console.log('') console.log('═══════════════════════════════════════════════') console.log(' 军歌嘹亮 · HBuilderX CLI 构建') console.log('═══════════════════════════════════════════════') console.log('') console.log(` HBuilderX ${HBX}`) console.log(` 平台 ${platform}`) // 环境体检:任一缺失都会导致产物异常或构建失败 const hbxNode = HBX_NODE_CANDIDATES.find((p) => fs.existsSync(p)) const issues = [] if (!fs.existsSync(HBX_TC)) issues.push(`缺失工具链目录:${HBX_TC}`) if (!fs.existsSync(UNI_JS)) issues.push(`缺失 uni CLI:${UNI_JS}`) if (!hbxNode) issues.push('未找到 HBuilderX 自带 node(plugins/node/node.exe 或 node18)') const patchedVue = path.join(HBX_TC, '@dcloudio', 'uni-h5-vue') if (!fs.existsSync(patchedVue)) { issues.push('未找到 @dcloudio/uni-h5-vue(uni-app 打过补丁的 Vue 运行时)') } console.log(` 构建 node ${hbxNode || '(未找到)'}`) if (issues.length) { console.log('') for (const i of issues) console.log(` ✗ ${i}`) console.log('') console.log(' 请确认 HBUILDERX_HOME 指向 HBuilderX 安装目录。') process.exit(1) } if (doctorOnly) { // 顺带验证补丁版 vue 是否真的导出 isInSSRComponentSetup const vueEs = path.join(patchedVue, 'dist', 'vue.runtime.esm.js') let patched = false try { patched = fs.readFileSync(vueEs, 'utf8').includes('isInSSRComponentSetup') } catch {} console.log(` Vue 运行时 ${patched ? '补丁版 OK(含 isInSSRComponentSetup)' : '异常:未检出 isInSSRComponentSetup'}`) if (!patched) process.exit(1) console.log('') console.log('✓ 环境体检通过(--doctor,未执行构建)') process.exit(0) } const { ok, created } = linkAll() if (!ok) process.exit(1) console.log(` 工具链联接 新建 ${created} 个(已存在的跳过)`) console.log('') if (linkOnly) { console.log('✓ 联接完成(--link-only,未执行构建)') process.exit(0) } const outputDir = path.join(ROOT, PLATFORM_OUTPUT[platform] || path.join('unpackage/dist/build', platform)) fs.mkdirSync(outputDir, { recursive: true }) const env = { ...process.env, HBUILDERX_HOME: HBX, // 缺 HX_APP_ROOT 时 uni-cli-shared 不会启用 HBuilderX 模块解析路径, // 构建会"成功"但产出 H5 空壳(没有 app.json / 业务分包)。 HX_APP_ROOT: HBX, UNI_HBUILDERX_PLUGINS: path.join(HBX, 'plugins'), UNI_INPUT_DIR: ROOT, UNI_OUTPUT_DIR: outputDir } delete env.UNI_PLATFORM // 由 CLI 的 -p 参数决定,预设会干扰平台判定 console.log('编译中…') const r = spawnSync(hbxNode, [UNI_JS, 'build', '-p', platform], { cwd: ROOT, env, stdio: 'inherit' }) console.log('') if (r.status !== 0) { console.log('✗ 构建失败') process.exit(r.status || 1) } // 产物校验:构建退出码为 0 不代表产物可用。 // 缺 HX_APP_ROOT 时 H5 会输出 index.html 空壳,mp 会缺 app.json。 const mustHave = platform.startsWith('mp-') ? ['app.json', 'app.js', 'app.wxss'] : ['index.html'] const missing = mustHave.filter((f) => !fs.existsSync(path.join(outputDir, f))) const jsCount = countFiles(outputDir, '.js') if (missing.length) { console.log(`✗ 构建报成功但产物不完整,缺少:${missing.join(', ')}`) console.log(' 多半是 HX_APP_ROOT 未生效(工具链退化为 H5 空壳输出)。') process.exit(1) } if (jsCount === 0) { console.log('✗ 产物中没有 JS 文件,构建未真正执行。') process.exit(1) } console.log(`✓ 构建完成(${jsCount} 个 JS 文件)`) if (wantReport) report(outputDir) console.log('') } main()