构建: - 新增 tools/build.js:junction HBuilderX 工具链,CLI 构建 h5/mp-weixin - vue 指向补丁版 @dcloudio/uni-h5-vue(官方 npm vue 不导出 isInSSRComponentSetup) - 设 HX_APP_ROOT 避免退化成 H5 空壳产物;产物完整性校验 校验工具: - 新增 check-cloud-methods.js:acorn 解析云对象方法,比对 94 处调用点 - 新增 check-android-contract.js:Kotlin 侧云对象契约校验 - audit-project.js 修 downloadFile 误报(注释未剥离);tools/ 排除出扫描 - package.json 声明此前隐式依赖的 acorn 功能: - 补 uni-cms-articles.getPublishedArticles(安卓端依赖但此前不存在) - 修 u-parse <audio> 引用已移除组件导致 H5 构建失败
305 lines
11 KiB
JavaScript
305 lines
11 KiB
JavaScript
#!/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 <platform>`
|
||
*
|
||
* 不用打开 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()
|