XuqmGroup-RNSDK/packages/update/scripts/xuqm-rn.mjs

423 行
14 KiB
JavaScript
可执行文件

#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import {
CONFIG_FILE_NAME,
SUPPORTED_PLATFORMS,
bundleFile,
bundleOutputDirectory,
compatibleAppRange,
parseModuleOptions,
readConfig,
resolveVersionedModules,
selectModules,
validateConfig,
} from './xuqm-config.mjs'
import { computeNativeBaseline } from './native-baseline.mjs'
const root = process.cwd()
const args = process.argv.slice(2)
const command = args.shift() ?? 'help'
function fail(message) {
console.error(`xuqm-rn: ${message}`)
process.exit(1)
}
function writeJson(file, value) {
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
}
function childEnvironment() {
const environment = { ...process.env }
// 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) {
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(),
})
}
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, {
schemaVersion: 3,
appId: pkg.name ?? 'react-native-app',
mainModuleName: pkg.name ?? 'ReactNativeApp',
pluginVersion: '1.0.0',
appVersionRange: compatibleAppRange(pkg.version ?? '1.0.0'),
outputDir: './bundle',
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['xuqm:doctor'] ??= 'xuqm-rn doctor'
pkg.scripts.start ??= 'xuqm-rn start'
pkg.scripts.android ??= 'xuqm-rn run android --extra-params "-PUSE_METRO=true"'
pkg.scripts['build:android'] ??= 'xuqm-rn embed android'
pkg.scripts['release:android'] ??= 'xuqm-rn package android'
pkg.scripts['publish:android'] ??= 'xuqm-rn publish android'
writeJson(packageFile, pkg)
console.log(`xuqm-rn: initialized. Edit ${CONFIG_FILE_NAME}, then run \`xuqm-rn doctor\`.`)
}
function doctor() {
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 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)
}
}
function build(platform, optionArgs = []) {
assertPlatform(platform)
const config = doctor()
const modules = parseSelectedModules(config, optionArgs)
for (const module of modules) {
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',
module.entry,
'--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)
if (module.sourceMap === true) {
cliArgs.push('--sourcemap-output', `${bundleFile(root, config, platform, module.id)}.map`)
}
console.log(`xuqm-rn: building ${module.id}/${platform}`)
runReactNative(cliArgs.slice(1))
}
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'),
)
}
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`
entries[module.id] = {
id: module.id,
type: module.type,
directory: module.directory ?? module.id,
bundleFile: bundleName,
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,
version: config.manifestVersion ?? 1,
nativeBaselineId,
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 nativeBaselineId = computeNativeBaseline(root)
for (const module of versioned.modules) {
const sourceRoot = bundleOutputDirectory(root, config, platform, module.id)
const bundleName = `${module.id}.${platform}.bundle`
for (const relative of [bundleName, ...moduleAssetFiles(sourceRoot, bundleName)]) {
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 { moduleIds } = parseModuleOptions(optionArgs)
const buildOptions = moduleIds.flatMap(moduleId => ['--module', moduleId])
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',
})
}
function packageApp(platform, optionArgs) {
assertPlatform(platform)
if (platform !== 'android') fail('native package command currently supports android only')
const apkIndex = optionArgs.indexOf('--apk')
const apk = apkIndex >= 0
if (apk) optionArgs.splice(apkIndex, 1)
if (optionArgs.length) fail(`unknown option(s): ${optionArgs.join(' ')}`)
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'], {
cwd: androidDirectory,
stdio: 'inherit',
})
}
function startMetro(optionArgs) {
runReactNative(['start', ...optionArgs])
}
function runApp(platform, optionArgs) {
assertPlatform(platform)
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
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 'plugin': {
if (args.shift() !== 'create') fail('usage: xuqm-rn plugin create <moduleId>')
createPlugin(args.shift())
break
}
case 'start':
startMetro(args)
break
case 'run':
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':
packageApp(args.shift() ?? 'android', args)
break
default:
printHelp()
}
} catch (error) {
fail(error instanceof Error ? error.message : String(error))
}