#!/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 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, 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' 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 androidApplicationId() { const gradleFile = ['android/app/build.gradle', 'android/app/build.gradle.kts'] .map(file => path.resolve(root, file)) .find(existsSync) if (!gradleFile) throw new Error('Android app Gradle file is missing') const match = readFileSync(gradleFile, 'utf8').match( /\bapplicationId\s*(?:=|\s)\s*["']([^"']+)["']/, ) if (!match?.[1]) throw new Error('Android applicationId could not be resolved') return match[1] } async function validateReleaseConfigOnline(platform) { await validateReleaseConfig({ projectRoot: root, packageName: platform === 'android' ? androidApplicationId() : undefined, 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, { 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', 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['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 ; 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() 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, 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 await validateReleaseConfigOnline(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) { runReactNative(['start', ...optionArgs]) } function runApp(platform, optionArgs) { assertPlatform(platform) runReactNative([platform === 'android' ? 'run-android' : 'run-ios', ...optionArgs]) } function printHelp() { console.log(`Usage: xuqm-rn [platform] [options] Commands: init Create the minimal host configuration doctor Validate host integration plugin create Create and register a plugin start Start Metro with a consistent child environment run Build/install a debug app (passes remaining options to RN CLI) build Build plugin bundles embed Build and embed every plugin bundle publish 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 Override the configured embedded output --app-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 ') 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': await packageApp(args.shift() ?? 'android', args) break default: printHelp() } } catch (error) { fail(error instanceof Error ? error.message : String(error)) }