834 行
30 KiB
JavaScript
可执行文件
834 行
30 KiB
JavaScript
可执行文件
#!/usr/bin/env node
|
|
|
|
import { execFileSync } from 'node:child_process'
|
|
import { createHash } from 'node:crypto'
|
|
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
renameSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import http from 'node:http'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { strToU8, zipSync } from 'fflate'
|
|
import {
|
|
CONFIG_FILE_NAME,
|
|
SUPPORTED_PLATFORMS,
|
|
bundleFile,
|
|
bundleOutputDirectory,
|
|
compatibleAppRange,
|
|
parseModuleOptions,
|
|
readConfig,
|
|
resolveHostPackageId,
|
|
resolveVersionedModules,
|
|
selectModules,
|
|
validateConfig,
|
|
} from './xuqm-config.mjs'
|
|
import { computeNativeBaseline } from './native-baseline.mjs'
|
|
import { parseBugCollectOption, parsePackageOptions } from './package-options.mjs'
|
|
import { validateReleaseConfig } from './release-config.mjs'
|
|
import { createAndroidReleaseIdentity } from './release-identity.mjs'
|
|
import { sanitizeSourceMapFile } from './source-map.mjs'
|
|
import { runProjectCheck, runProjectFix } from './project-check.mjs'
|
|
import { validateSourceBoundaries } from './source-boundaries.mjs'
|
|
|
|
const root = process.cwd()
|
|
const args = process.argv.slice(2)
|
|
const command = args.shift() ?? 'help'
|
|
const scriptRequire = createRequire(import.meta.url)
|
|
const { ensureGeneratedEntries } = scriptRequire('./generated-entries.cjs')
|
|
|
|
function fail(message) {
|
|
console.error(`xuqm-rn: ${message}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
function writeJson(file, value) {
|
|
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
|
|
}
|
|
|
|
async function validateReleaseConfigOnline(config, platform) {
|
|
await validateReleaseConfig({
|
|
projectRoot: root,
|
|
packageName: resolveHostPackageId(config, platform),
|
|
})
|
|
}
|
|
|
|
function childEnvironment(overrides = {}) {
|
|
const environment = { ...process.env, ...overrides }
|
|
// Metro may set FORCE_COLOR in its transformer workers. Forward a single
|
|
// explicit color flag so those workers never inherit a contradictory pair.
|
|
if (environment.NO_COLOR !== undefined) {
|
|
delete environment.NO_COLOR
|
|
environment.FORCE_COLOR = '0'
|
|
}
|
|
return environment
|
|
}
|
|
|
|
function runReactNative(cliArgs, environment = {}) {
|
|
const reactNativeCli = path.join(root, 'node_modules', 'react-native', 'cli.js')
|
|
if (!existsSync(reactNativeCli)) fail('react-native CLI is missing; install dependencies first')
|
|
execFileSync(process.execPath, [reactNativeCli, ...cliArgs], {
|
|
stdio: 'inherit',
|
|
cwd: root,
|
|
env: childEnvironment(environment),
|
|
})
|
|
}
|
|
|
|
function androidUsesHermes() {
|
|
const propertiesFile = path.join(root, 'android', 'gradle.properties')
|
|
if (!existsSync(propertiesFile)) return false
|
|
const properties = readFileSync(propertiesFile, 'utf8')
|
|
const match = properties.match(/^\s*hermesEnabled\s*=\s*(true|false)\s*$/im)
|
|
return match?.[1].toLowerCase() === 'true'
|
|
}
|
|
|
|
function resolveHermesCompiler() {
|
|
const hostRequire = createRequire(path.join(root, 'package.json'))
|
|
const reactNativeManifest = hostRequire.resolve('react-native/package.json')
|
|
const reactNativeRequire = createRequire(reactNativeManifest)
|
|
const compilerManifest = reactNativeRequire.resolve('hermes-compiler/package.json')
|
|
const osDirectory =
|
|
process.platform === 'darwin'
|
|
? 'osx-bin'
|
|
: process.platform === 'linux' && process.arch === 'x64'
|
|
? 'linux64-bin'
|
|
: process.platform === 'win32'
|
|
? 'win64-bin'
|
|
: null
|
|
if (!osDirectory) {
|
|
fail(`Hermes compiler does not support ${process.platform}/${process.arch}`)
|
|
}
|
|
const executable = process.platform === 'win32' ? 'hermesc.exe' : 'hermesc'
|
|
const compiler = path.join(path.dirname(compilerManifest), 'hermesc', osDirectory, executable)
|
|
if (!existsSync(compiler)) fail(`Hermes compiler is missing: ${compiler}`)
|
|
return { compiler, reactNativeDirectory: path.dirname(reactNativeManifest) }
|
|
}
|
|
|
|
function compileHermesBundle(output, sourceMapEnabled) {
|
|
const { compiler, reactNativeDirectory } = resolveHermesCompiler()
|
|
const bytecode = `${output}.hbc`
|
|
const packagerSourceMap = `${output}.packager.map`
|
|
const compilerSourceMap = `${bytecode}.map`
|
|
const outputSourceMap = `${output}.map`
|
|
|
|
if (sourceMapEnabled) renameSync(outputSourceMap, packagerSourceMap)
|
|
execFileSync(
|
|
compiler,
|
|
[
|
|
'-w',
|
|
'-emit-binary',
|
|
'-max-diagnostic-width=80',
|
|
'-out',
|
|
bytecode,
|
|
output,
|
|
'-O',
|
|
...(sourceMapEnabled ? ['-output-source-map'] : []),
|
|
],
|
|
{ cwd: root, stdio: 'inherit' },
|
|
)
|
|
rmSync(output)
|
|
renameSync(bytecode, output)
|
|
|
|
if (sourceMapEnabled) {
|
|
const composeSourceMaps = path.join(reactNativeDirectory, 'scripts', 'compose-source-maps.js')
|
|
execFileSync(
|
|
process.execPath,
|
|
[composeSourceMaps, packagerSourceMap, compilerSourceMap, '-o', outputSourceMap],
|
|
{ cwd: root, stdio: 'inherit' },
|
|
)
|
|
rmSync(packagerSourceMap)
|
|
rmSync(compilerSourceMap)
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
const packageFile = path.join(root, 'package.json')
|
|
if (!existsSync(packageFile)) fail('run this command from a React Native project root')
|
|
const configFile = path.join(root, CONFIG_FILE_NAME)
|
|
if (!existsSync(configFile)) {
|
|
const pkg = JSON.parse(readFileSync(packageFile, 'utf8'))
|
|
writeJson(configFile, {
|
|
$schema: './node_modules/@xuqm/rn-update/schema/xuqm.modules.schema.json',
|
|
schemaVersion: 3,
|
|
appId: pkg.name ?? 'react-native-app',
|
|
packageName: pkg.name ?? 'react-native-app',
|
|
mainModuleName: pkg.name ?? 'ReactNativeApp',
|
|
pluginVersion: '1.0.0',
|
|
appVersionRange: compatibleAppRange(pkg.version ?? '1.0.0'),
|
|
outputDir: './bundle',
|
|
metroConfig: './metro.config.js',
|
|
embeddedOutput: {
|
|
android: './android/app/src/main/assets/rn-bundles',
|
|
ios: './bundle/embedded/ios/rn-bundles',
|
|
},
|
|
modules: [
|
|
{
|
|
id: 'common',
|
|
type: 'common',
|
|
entry: './src/bootstrap/common.ts',
|
|
},
|
|
{
|
|
id: 'app',
|
|
type: 'app',
|
|
entry: './src/bootstrap/app.ts',
|
|
},
|
|
],
|
|
})
|
|
}
|
|
const pkg = JSON.parse(readFileSync(packageFile, 'utf8'))
|
|
pkg.scripts ??= {}
|
|
pkg.scripts.start ??= 'xuqm-rn start'
|
|
pkg.scripts.android ??= 'xuqm-rn run android --extra-params "-PUSE_METRO=true"'
|
|
pkg.scripts.ios ??= 'xuqm-rn run ios'
|
|
pkg.scripts.check ??= 'xuqm-rn check'
|
|
pkg.scripts['release:android'] ??= 'xuqm-rn package android'
|
|
writeJson(packageFile, pkg)
|
|
console.log(`xuqm-rn: initialized. Edit ${CONFIG_FILE_NAME}, then run \`xuqm-rn doctor\`.`)
|
|
}
|
|
|
|
function doctor() {
|
|
ensureGeneratedEntries(root)
|
|
const config = readConfig(root)
|
|
const errors = validateConfig(config, root)
|
|
const packageFile = path.join(root, 'package.json')
|
|
if (!existsSync(packageFile)) errors.push('package.json is missing')
|
|
else {
|
|
const pkg = JSON.parse(readFileSync(packageFile, 'utf8'))
|
|
if (typeof pkg.version !== 'string' || pkg.version.trim() === '') {
|
|
errors.push('package.json version must be a non-empty string')
|
|
}
|
|
if (!pkg.dependencies?.['@xuqm/rn-update']) {
|
|
errors.push('@xuqm/rn-update must be a dependency')
|
|
}
|
|
}
|
|
if (errors.length) fail(`doctor failed:\n- ${errors.join('\n- ')}`)
|
|
console.log(`xuqm-rn: doctor passed (${config.modules.length} modules).`)
|
|
return config
|
|
}
|
|
|
|
function checkProject() {
|
|
const config = doctor()
|
|
const boundaryViolations = validateSourceBoundaries(root, config)
|
|
if (boundaryViolations.length > 0) {
|
|
fail(`source boundary check failed:\n- ${boundaryViolations.join('\n- ')}`)
|
|
}
|
|
const projectCheckRan = runProjectCheck(root)
|
|
console.log(
|
|
`xuqm-rn: check passed (${config.modules.length} modules${
|
|
projectCheckRan ? ', project rules included' : ''
|
|
}).`,
|
|
)
|
|
}
|
|
|
|
function fixProject() {
|
|
ensureGeneratedEntries(root)
|
|
const projectFixRan = runProjectFix(root)
|
|
if (!projectFixRan) {
|
|
console.log('xuqm-rn: no project fix hook; generated entries are current.')
|
|
}
|
|
checkProject()
|
|
}
|
|
|
|
function createPlugin(moduleId) {
|
|
if (!/^[a-z][a-z0-9-]*$/.test(moduleId ?? '')) {
|
|
fail('usage: xuqm-rn plugin create <moduleId>; id must match /^[a-z][a-z0-9-]*$/')
|
|
}
|
|
const config = readConfig(root)
|
|
if (config.modules.some(module => module.id === moduleId)) {
|
|
fail(`module "${moduleId}" already exists`)
|
|
}
|
|
const directory = path.join(root, 'src', 'plugins', moduleId)
|
|
if (existsSync(directory)) fail(`directory already exists: ${path.relative(root, directory)}`)
|
|
mkdirSync(directory, { recursive: true })
|
|
const entry = path.join(directory, 'bundle.ts')
|
|
writeFileSync(
|
|
entry,
|
|
`import { definePlugin } from '@xuqm/rn-update'\n\ndefinePlugin({\n moduleId: '${moduleId}',\n activate(context) {\n context.emit('plugin:ready', { moduleId: '${moduleId}' })\n },\n})\n`,
|
|
)
|
|
config.modules.push({
|
|
id: moduleId,
|
|
type: 'buz',
|
|
entry: `./src/plugins/${moduleId}/bundle.ts`,
|
|
title: moduleId,
|
|
})
|
|
writeJson(path.join(root, CONFIG_FILE_NAME), config)
|
|
console.log(`xuqm-rn: created plugin "${moduleId}" and registered it in ${CONFIG_FILE_NAME}.`)
|
|
}
|
|
|
|
function assertPlatform(platform) {
|
|
if (!SUPPORTED_PLATFORMS.has(platform)) {
|
|
fail('platform must be android or ios')
|
|
}
|
|
}
|
|
|
|
function parseSelectedModules(config, optionArgs) {
|
|
const { moduleIds, remaining } = parseModuleOptions(optionArgs)
|
|
if (remaining.length) fail(`unknown option(s): ${remaining.join(' ')}`)
|
|
try {
|
|
return selectModules(config, moduleIds)
|
|
} catch (error) {
|
|
fail(error.message)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 独立构建 app/buz 时也必须先生成 startup/common 的共享模块表。
|
|
* 依赖 bundle 只参与本次构建,不会因为选择了单个 buz 就被一并发布。
|
|
*/
|
|
function resolveBuildModules(config, selectedModules) {
|
|
const selectedIds = new Set(selectedModules.map(module => module.id))
|
|
const needsCommon = selectedModules.some(module => ['app', 'buz'].includes(module.type))
|
|
const needsStartup = selectedModules.some(module => module.type !== 'startup')
|
|
const priorities = { startup: 0, common: 1, app: 2, buz: 3 }
|
|
return config.modules
|
|
.filter(
|
|
module =>
|
|
selectedIds.has(module.id) ||
|
|
(needsCommon && module.type === 'common') ||
|
|
(needsStartup && module.type === 'startup'),
|
|
)
|
|
.sort((left, right) => priorities[left.type] - priorities[right.type])
|
|
}
|
|
|
|
const OWNED_SOURCE_EXTENSION = /\.(?:js|jsx|ts|tsx)$/
|
|
const EXCLUDED_OWNERSHIP_SOURCE =
|
|
/(?:^|\/)(?:__tests__|tests?)(?:\/|$)|\.(?:spec|test)\.[^.]+$|\.d\.ts$/
|
|
|
|
function ownershipSourceFiles(directory, platform) {
|
|
const selected = new Map()
|
|
for (const relative of walkFiles(directory)) {
|
|
if (!OWNED_SOURCE_EXTENSION.test(relative) || EXCLUDED_OWNERSHIP_SOURCE.test(relative)) continue
|
|
const platformMatch = relative.match(/\.(android|ios|native|web)(?=\.[^.]+$)/)
|
|
const variant = platformMatch?.[1]
|
|
if (variant && variant !== platform && variant !== 'native') continue
|
|
const key = platformMatch ? relative.replace(`.${variant}`, '') : relative
|
|
const priority = variant === platform ? 3 : variant === 'native' ? 2 : 1
|
|
const existing = selected.get(key)
|
|
if (!existing || priority > existing.priority) {
|
|
selected.set(key, { absolute: path.join(directory, relative), priority })
|
|
}
|
|
}
|
|
return [...selected.values()].map(item => item.absolute).sort()
|
|
}
|
|
|
|
/**
|
|
* common 可声明若干唯一所有权目录。CLI 为当前平台生成一个临时入口,把这些目录
|
|
* 纳入 common 的依赖图但不执行模块;后续 app/buz 会按共享模块表过滤它们。
|
|
* 目录规则由 SDK 统一实现,宿主不再维护易遗漏的 require 清单。
|
|
*/
|
|
function buildEntryFile(module, platform) {
|
|
if (module.type !== 'common' || !module.ownershipRoots?.length) return module.entry
|
|
const generatedDirectory = path.join(root, '.xuqm-cache', 'ownership')
|
|
const generatedFile = path.join(generatedDirectory, `${module.id}.${platform}.js`)
|
|
const ownedFiles = module.ownershipRoots.flatMap(ownershipRoot =>
|
|
ownershipSourceFiles(path.resolve(root, ownershipRoot), platform),
|
|
)
|
|
const importPath = file => {
|
|
const relative = path.relative(generatedDirectory, file).split(path.sep).join('/')
|
|
return relative.startsWith('.') ? relative : `./${relative}`
|
|
}
|
|
const lines = [
|
|
`require(${JSON.stringify(importPath(path.resolve(root, module.entry)))})`,
|
|
'',
|
|
'function retainXuqmCommonOwnership() {',
|
|
...ownedFiles.map(file => ` require(${JSON.stringify(importPath(file))})`),
|
|
'}',
|
|
'',
|
|
'void retainXuqmCommonOwnership',
|
|
'',
|
|
]
|
|
mkdirSync(generatedDirectory, { recursive: true })
|
|
writeFileSync(generatedFile, lines.join('\n'))
|
|
return generatedFile
|
|
}
|
|
|
|
function build(platform, optionArgs = []) {
|
|
assertPlatform(platform)
|
|
const config = doctor()
|
|
resolveHostPackageId(config, platform)
|
|
const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION)
|
|
const versionedConfig = { ...config, modules: versioned.modules }
|
|
const modules = parseSelectedModules(versionedConfig, optionArgs)
|
|
const buildModules = resolveBuildModules(versionedConfig, modules)
|
|
const useHermes = platform === 'android' && androidUsesHermes()
|
|
const nativeBaselineId = computeNativeBaseline(root, platform)
|
|
for (const [buildIndex, module] of buildModules.entries()) {
|
|
const outputDirectory = bundleOutputDirectory(root, config, platform, module.id)
|
|
rmSync(outputDirectory, { force: true, recursive: true })
|
|
mkdirSync(outputDirectory, { recursive: true })
|
|
const cliArgs = [
|
|
'react-native',
|
|
'bundle',
|
|
'--platform',
|
|
platform,
|
|
'--dev',
|
|
'false',
|
|
'--entry-file',
|
|
buildEntryFile(module, platform),
|
|
'--bundle-output',
|
|
bundleFile(root, config, platform, module.id),
|
|
'--assets-dest',
|
|
outputDirectory,
|
|
'--reset-cache',
|
|
]
|
|
const metroConfig = module.metroConfig ?? config.metroConfig
|
|
if (metroConfig) cliArgs.push('--config', metroConfig)
|
|
// 每个 Release 模块始终生成独立 Source Map;是否上传由发布阶段决定。
|
|
cliArgs.push('--sourcemap-output', `${bundleFile(root, config, platform, module.id)}.map`)
|
|
console.log(`xuqm-rn: building ${module.id}/${platform}`)
|
|
runReactNative(cliArgs.slice(1), {
|
|
XUQM_MODULE_CACHE_FILE: path.join(root, '.xuqm-cache', 'common-module-ids.json'),
|
|
XUQM_MODULE_ID: module.id,
|
|
XUQM_MODULE_INDEX: String(config.modules.findIndex(item => item.id === module.id)),
|
|
XUQM_MODULE_TYPE: module.type,
|
|
XUQM_RESET_MODULE_CACHE: buildIndex === 0 ? 'true' : 'false',
|
|
})
|
|
if (useHermes) compileHermesBundle(bundleFile(root, config, platform, module.id), true)
|
|
sanitizeSourceMapFile(`${bundleFile(root, config, platform, module.id)}.map`, root)
|
|
// startup 是随宿主 APK 固化的最小引导代码,不属于可在线发布插件。为它生成
|
|
// 发布包既没有安装入口,也容易让发布人员误认为它可以脱离整包升级。
|
|
if (module.type !== 'startup') {
|
|
createModulePackage(
|
|
config,
|
|
platform,
|
|
module,
|
|
versioned.appVersion,
|
|
nativeBaselineId,
|
|
useHermes ? 'hermes-bytecode' : 'javascript',
|
|
)
|
|
}
|
|
}
|
|
return { config, modules }
|
|
}
|
|
|
|
function walkFiles(directory, relativePrefix = '') {
|
|
const files = []
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
const relative = relativePrefix ? path.join(relativePrefix, entry.name) : entry.name
|
|
const absolute = path.join(directory, entry.name)
|
|
if (entry.isDirectory()) files.push(...walkFiles(absolute, relative))
|
|
else files.push(relative.split(path.sep).join('/'))
|
|
}
|
|
return files.sort()
|
|
}
|
|
|
|
function moduleAssetFiles(rootDirectory, bundleName) {
|
|
return walkFiles(rootDirectory).filter(
|
|
file =>
|
|
file !== bundleName &&
|
|
file !== 'raw/keep.xml' &&
|
|
!file.endsWith('.map') &&
|
|
!file.endsWith('.xuqm.zip'),
|
|
)
|
|
}
|
|
|
|
function modulePackageFile(config, platform, moduleId) {
|
|
return path.join(
|
|
bundleOutputDirectory(root, config, platform, moduleId),
|
|
`${moduleId}.${platform}.xuqm.zip`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 线上插件与 APK 内嵌插件共用同一份模块元数据。发布物始终是二进制 ZIP,避免
|
|
* Hermes 字节码经过 UTF-8 转换损坏,并让插件自己的图片、字体等资源原子到达。
|
|
*/
|
|
function createModulePackage(config, platform, module, appVersion, nativeBaselineId, bundleFormat) {
|
|
const directory = bundleOutputDirectory(root, config, platform, module.id)
|
|
const bundleName = `${module.id}.${platform}.bundle`
|
|
const bundleBytes = readFileSync(path.join(directory, bundleName))
|
|
const assetFiles = moduleAssetFiles(directory, bundleName)
|
|
const assets = assetFiles.map(relative => {
|
|
const bytes = readFileSync(path.join(directory, relative))
|
|
return {
|
|
path: relative,
|
|
byteLength: bytes.byteLength,
|
|
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
}
|
|
})
|
|
const manifest = {
|
|
schemaVersion: 1,
|
|
packageName: resolveHostPackageId(config, platform),
|
|
moduleId: module.id,
|
|
type: module.type,
|
|
platform,
|
|
version: module.moduleVersion,
|
|
appVersion,
|
|
appVersionRange: module.appVersionRange,
|
|
builtAgainstNativeBaselineId: nativeBaselineId,
|
|
bundleFormat,
|
|
buildId: process.env.XUQM_BUILD_ID ?? 'development',
|
|
bugCollectMode: process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
|
|
bundleFile: bundleName,
|
|
bundleByteLength: bundleBytes.byteLength,
|
|
bundleSha256: createHash('sha256').update(bundleBytes).digest('hex'),
|
|
...(module.commonVersionRange ? { commonVersionRange: module.commonVersionRange } : {}),
|
|
minNativeApiLevel: module.minNativeApiLevel,
|
|
assets,
|
|
}
|
|
const archive = {
|
|
'rn-manifest.json': strToU8(`${JSON.stringify(manifest, null, 2)}\n`),
|
|
[bundleName]: new Uint8Array(bundleBytes),
|
|
}
|
|
for (const asset of assets) {
|
|
archive[`assets/${asset.path}`] = new Uint8Array(readFileSync(path.join(directory, asset.path)))
|
|
}
|
|
writeFileSync(modulePackageFile(config, platform, module.id), zipSync(archive, { level: 9 }))
|
|
}
|
|
|
|
function copyWithoutConflict(source, target, owners, owner) {
|
|
const relative = path.relative(path.dirname(target), target)
|
|
if (existsSync(target)) {
|
|
const sourceBytes = readFileSync(source)
|
|
const targetBytes = readFileSync(target)
|
|
if (!sourceBytes.equals(targetBytes)) {
|
|
throw new Error(`asset collision between ${owners.get(target)} and ${owner}: ${relative}`)
|
|
}
|
|
return
|
|
}
|
|
mkdirSync(path.dirname(target), { recursive: true })
|
|
copyFileSync(source, target)
|
|
owners.set(target, owner)
|
|
}
|
|
|
|
function createManifest(config, platform, modules, appVersion, nativeBaselineId) {
|
|
const entries = {}
|
|
for (const module of modules) {
|
|
const directory = bundleOutputDirectory(root, config, platform, module.id)
|
|
const bundleName = `${module.id}.${platform}.bundle`
|
|
const bundleBytes = readFileSync(path.join(directory, bundleName))
|
|
entries[module.id] = {
|
|
id: module.id,
|
|
type: module.type,
|
|
directory: module.directory ?? module.id,
|
|
bundleFile: bundleName,
|
|
sha256: createHash('sha256').update(bundleBytes).digest('hex'),
|
|
byteLength: bundleBytes.byteLength,
|
|
moduleVersion: module.moduleVersion,
|
|
appVersionRange: module.appVersionRange,
|
|
builtAgainstNativeBaselineId: nativeBaselineId,
|
|
...(module.commonVersionRange ? { commonVersionRange: module.commonVersionRange } : {}),
|
|
minNativeApiLevel: module.minNativeApiLevel,
|
|
assetFiles: moduleAssetFiles(directory, bundleName),
|
|
...Object.fromEntries(
|
|
['title', 'summary', 'description', 'accentColor', 'uniqueId', 'link']
|
|
.filter(key => module[key] !== undefined)
|
|
.map(key => [key, module[key]]),
|
|
),
|
|
}
|
|
}
|
|
return {
|
|
appId: config.appId,
|
|
appVersion,
|
|
mainModuleName: config.mainModuleName,
|
|
platform,
|
|
bundleFormat: platform === 'android' && androidUsesHermes() ? 'hermes-bytecode' : 'javascript',
|
|
version: config.manifestVersion ?? 1,
|
|
nativeBaselineId,
|
|
buildId: process.env.XUQM_BUILD_ID ?? 'development',
|
|
bugCollectMode: process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
|
|
modules: entries,
|
|
}
|
|
}
|
|
|
|
function parseEmbedOptions(optionArgs) {
|
|
let skipBuild = false
|
|
let output
|
|
let appVersion
|
|
for (let index = 0; index < optionArgs.length; index += 1) {
|
|
const option = optionArgs[index]
|
|
if (option === '--skip-build') {
|
|
skipBuild = true
|
|
continue
|
|
}
|
|
if (option === '--output' || option === '--app-version') {
|
|
const value = optionArgs[index + 1]
|
|
if (!value || value.startsWith('--')) fail(`${option} requires a value`)
|
|
if (option === '--output') output = value
|
|
else appVersion = value
|
|
index += 1
|
|
continue
|
|
}
|
|
fail(`unknown option: ${option}`)
|
|
}
|
|
return { skipBuild, output, appVersion }
|
|
}
|
|
|
|
function embed(platform, optionArgs = []) {
|
|
assertPlatform(platform)
|
|
const options = parseEmbedOptions(optionArgs)
|
|
const config = options.skipBuild ? doctor() : build(platform).config
|
|
const versioned = resolveVersionedModules(
|
|
config,
|
|
root,
|
|
options.appVersion ?? process.env.XUQM_APP_VERSION,
|
|
)
|
|
const targetSetting = options.output ?? config.embeddedOutput?.[platform]
|
|
if (!targetSetting) fail(`embeddedOutput.${platform} is required for embed`)
|
|
const target = path.resolve(root, targetSetting)
|
|
rmSync(target, { force: true, recursive: true })
|
|
mkdirSync(target, { recursive: true })
|
|
const owners = new Map()
|
|
const assetOwners = new Map()
|
|
const nativeBaselineId = computeNativeBaseline(root, platform)
|
|
for (const module of versioned.modules) {
|
|
const sourceRoot = bundleOutputDirectory(root, config, platform, module.id)
|
|
const bundleName = `${module.id}.${platform}.bundle`
|
|
const assets = moduleAssetFiles(sourceRoot, bundleName)
|
|
for (const relative of assets) {
|
|
const existingOwner = assetOwners.get(relative)
|
|
if (existingOwner) {
|
|
throw new Error(
|
|
`asset ownership collision: ${relative} belongs to both ${existingOwner} and ${module.id}; move shared assets into common ownershipRoots`,
|
|
)
|
|
}
|
|
assetOwners.set(relative, module.id)
|
|
}
|
|
for (const relative of [bundleName, ...assets]) {
|
|
copyWithoutConflict(
|
|
path.join(sourceRoot, relative),
|
|
path.join(target, relative),
|
|
owners,
|
|
module.id,
|
|
)
|
|
}
|
|
}
|
|
writeJson(
|
|
path.join(target, 'manifest.json'),
|
|
createManifest(config, platform, versioned.modules, versioned.appVersion, nativeBaselineId),
|
|
)
|
|
console.log(
|
|
`xuqm-rn: embedded ${versioned.modules.length} modules at ${target} (app ${versioned.appVersion})`,
|
|
)
|
|
}
|
|
|
|
function publish(platform, optionArgs) {
|
|
const { bugCollectMode, remaining } = parseBugCollectOption(
|
|
optionArgs,
|
|
process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
|
|
)
|
|
const { moduleIds } = parseModuleOptions(remaining)
|
|
const buildOptions = moduleIds.flatMap(moduleId => ['--module', moduleId])
|
|
const previousBuildId = process.env.XUQM_BUILD_ID
|
|
const previousVersionCode = process.env.XUQM_VERSION_CODE
|
|
const previousBugCollectMode = process.env.XUQM_BUGCOLLECT_MODE
|
|
if (platform === 'android' && !previousBuildId) {
|
|
const identity = createAndroidReleaseIdentity({ projectRoot: root })
|
|
process.env.XUQM_BUILD_ID = identity.buildId
|
|
process.env.XUQM_VERSION_CODE = String(identity.versionCode)
|
|
}
|
|
process.env.XUQM_BUGCOLLECT_MODE = bugCollectMode
|
|
try {
|
|
build(platform, buildOptions)
|
|
const releaseScript = new URL('./xuqm_release.mjs', import.meta.url)
|
|
execFileSync(
|
|
process.execPath,
|
|
[releaseScript.pathname, '--platform', platform, ...optionArgs],
|
|
{
|
|
cwd: root,
|
|
stdio: 'inherit',
|
|
},
|
|
)
|
|
} finally {
|
|
if (previousBuildId === undefined) delete process.env.XUQM_BUILD_ID
|
|
else process.env.XUQM_BUILD_ID = previousBuildId
|
|
if (previousVersionCode === undefined) delete process.env.XUQM_VERSION_CODE
|
|
else process.env.XUQM_VERSION_CODE = previousVersionCode
|
|
if (previousBugCollectMode === undefined) delete process.env.XUQM_BUGCOLLECT_MODE
|
|
else process.env.XUQM_BUGCOLLECT_MODE = previousBugCollectMode
|
|
}
|
|
}
|
|
|
|
async function packageApp(platform, optionArgs) {
|
|
assertPlatform(platform)
|
|
if (platform !== 'android') fail('native package command currently supports android only')
|
|
let packageOptions
|
|
try {
|
|
packageOptions = parsePackageOptions(optionArgs)
|
|
} catch (error) {
|
|
fail(error instanceof Error ? error.message : String(error))
|
|
}
|
|
const { apk, bugCollectMode } = packageOptions
|
|
const config = doctor()
|
|
await validateReleaseConfigOnline(config, platform)
|
|
const buildIdentity = createAndroidReleaseIdentity({ projectRoot: root })
|
|
const androidDirectory = path.join(root, 'android')
|
|
const wrapper = path.join(
|
|
androidDirectory,
|
|
process.platform === 'win32' ? 'gradlew.bat' : 'gradlew',
|
|
)
|
|
if (!existsSync(wrapper)) fail('Android Gradle wrapper is missing')
|
|
execFileSync(
|
|
wrapper,
|
|
[
|
|
apk ? 'assembleRelease' : 'bundleRelease',
|
|
`-PXUQM_VERSION_CODE=${buildIdentity.versionCode}`,
|
|
`-PXUQM_BUILD_ID=${buildIdentity.buildId}`,
|
|
`-PXUQM_BUGCOLLECT_MODE=${bugCollectMode}`,
|
|
],
|
|
{
|
|
cwd: androidDirectory,
|
|
stdio: 'inherit',
|
|
env: childEnvironment({
|
|
XUQM_BUGCOLLECT_MODE: bugCollectMode,
|
|
XUQM_BUILD_ID: buildIdentity.buildId,
|
|
XUQM_VERSION_CODE: String(buildIdentity.versionCode),
|
|
}),
|
|
},
|
|
)
|
|
console.log(
|
|
`xuqm-rn: release identity versionCode=${buildIdentity.versionCode} buildId=${buildIdentity.buildId}`,
|
|
)
|
|
}
|
|
|
|
function startMetro(optionArgs) {
|
|
doctor()
|
|
runReactNative(['start', ...optionArgs])
|
|
}
|
|
|
|
function optionValue(optionArgs, name) {
|
|
const index = optionArgs.indexOf(name)
|
|
return index >= 0 ? optionArgs[index + 1] : undefined
|
|
}
|
|
|
|
function readMetroIdentity(port) {
|
|
return new Promise(resolve => {
|
|
const request = http.get(
|
|
{
|
|
host: '127.0.0.1',
|
|
path: '/xuqm/metro-info',
|
|
port,
|
|
timeout: 800,
|
|
},
|
|
response => {
|
|
let body = ''
|
|
response.setEncoding('utf8')
|
|
response.on('data', chunk => {
|
|
body += chunk
|
|
})
|
|
response.on('end', () => {
|
|
if (response.statusCode !== 200) {
|
|
resolve({ kind: 'unmanaged' })
|
|
return
|
|
}
|
|
try {
|
|
const value = JSON.parse(body)
|
|
resolve(
|
|
typeof value?.projectRoot === 'string'
|
|
? { kind: 'managed', projectRoot: path.resolve(value.projectRoot) }
|
|
: { kind: 'unmanaged' },
|
|
)
|
|
} catch {
|
|
resolve({ kind: 'unmanaged' })
|
|
}
|
|
})
|
|
},
|
|
)
|
|
request.on('timeout', () => request.destroy())
|
|
request.on('error', error => {
|
|
resolve(
|
|
error?.code === 'ECONNREFUSED' || error?.code === 'ECONNRESET'
|
|
? { kind: 'absent' }
|
|
: { kind: 'unmanaged' },
|
|
)
|
|
})
|
|
})
|
|
}
|
|
|
|
async function assertMetroBelongsToCurrentProject(optionArgs) {
|
|
if (optionArgs.includes('--no-packager')) return
|
|
const parsedPort = Number.parseInt(optionValue(optionArgs, '--port') ?? '8081', 10)
|
|
const port = Number.isInteger(parsedPort) && parsedPort > 0 ? parsedPort : 8081
|
|
const identity = await readMetroIdentity(port)
|
|
if (identity.kind === 'absent') return
|
|
if (identity.kind === 'managed' && identity.projectRoot === path.resolve(root)) return
|
|
if (identity.kind === 'managed') {
|
|
fail(
|
|
`Metro port ${port} belongs to ${identity.projectRoot}; stop it or choose another --port before running this project`,
|
|
)
|
|
}
|
|
fail(
|
|
`Metro port ${port} is occupied by an unidentified or outdated server; restart it with \`xuqm-rn start --reset-cache\``,
|
|
)
|
|
}
|
|
|
|
async function runApp(platform, optionArgs) {
|
|
assertPlatform(platform)
|
|
doctor()
|
|
await assertMetroBelongsToCurrentProject(optionArgs)
|
|
runReactNative([platform === 'android' ? 'run-android' : 'run-ios', ...optionArgs])
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`Usage: xuqm-rn <command> [platform] [options]
|
|
|
|
Commands:
|
|
init Create the minimal host configuration
|
|
doctor Validate host integration
|
|
check Validate SDK integration and project quality rules
|
|
fix Regenerate safe artifacts, then run check
|
|
plugin create <id> Create and register a plugin
|
|
start Start Metro with a consistent child environment
|
|
run <platform> Build/install a debug app (passes remaining options to RN CLI)
|
|
build <platform> Build plugin bundles
|
|
embed <platform> Build and embed every plugin bundle
|
|
publish <platform> Upload plugin bundles to the update service
|
|
package android [--apk] Build a release AAB (or APK) with embedded plugins
|
|
|
|
Embed options:
|
|
--skip-build Embed existing bundle outputs
|
|
--output <directory> Override the configured embedded output
|
|
--app-version <version> Override package.json.version for CI`)
|
|
}
|
|
|
|
try {
|
|
switch (command) {
|
|
case 'init':
|
|
init()
|
|
break
|
|
case 'doctor':
|
|
doctor()
|
|
break
|
|
case 'check':
|
|
checkProject()
|
|
break
|
|
case 'fix':
|
|
fixProject()
|
|
break
|
|
case 'plugin': {
|
|
if (args.shift() !== 'create') fail('usage: xuqm-rn plugin create <moduleId>')
|
|
createPlugin(args.shift())
|
|
break
|
|
}
|
|
case 'start':
|
|
startMetro(args)
|
|
break
|
|
case 'run':
|
|
await runApp(args.shift() ?? 'android', args)
|
|
break
|
|
case 'build':
|
|
build(args.shift() ?? 'android', args)
|
|
break
|
|
case 'embed':
|
|
embed(args.shift() ?? 'android', args)
|
|
break
|
|
case 'publish':
|
|
publish(args.shift() ?? 'android', args)
|
|
break
|
|
case 'package':
|
|
await packageApp(args.shift() ?? 'android', args)
|
|
break
|
|
default:
|
|
printHelp()
|
|
}
|
|
} catch (error) {
|
|
fail(error instanceof Error ? error.message : String(error))
|
|
}
|