#!/usr/bin/env node import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import { createInterface } from 'node:readline' import process from 'node:process' import { SUPPORTED_PLATFORMS, bundleOutputDirectory, parseModuleOptions, readConfig, resolveVersionedModules, selectModules, } from './xuqm-config.mjs' import { computeNativeBaseline } from './native-baseline.mjs' const root = process.cwd() const rawArgs = process.argv.slice(2) function takeOption(name) { const index = rawArgs.indexOf(name) if (index < 0) return undefined const value = rawArgs[index + 1] if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`) rawArgs.splice(index, 2) return value } const platform = takeOption('--platform') ?? 'android' const noteOption = takeOption('--note') const minNativeApiLevelOption = takeOption('--min-native-api-level') const publishOptionIndex = rawArgs.indexOf('--publish') const publishOption = publishOptionIndex >= 0 if (publishOption) rawArgs.splice(publishOptionIndex, 1) const { moduleIds, remaining } = parseModuleOptions(rawArgs) if (remaining.length) throw new Error(`unknown option(s): ${remaining.join(' ')}`) if (!SUPPORTED_PLATFORMS.has(platform)) throw new Error('platform must be android or ios') const config = readConfig(root) const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION) const selectedModules = selectModules({ ...config, modules: versioned.modules }, moduleIds) if (moduleIds.length > 0 && selectedModules.some(module => module.type === 'startup')) { throw new Error('startup is embedded in the host APK and cannot be published independently') } const modules = selectedModules.filter(module => module.type !== 'startup') if (modules.length === 0) throw new Error('no publishable common/app/buz module selected') const nativeBaselineId = computeNativeBaseline(root, platform) const serverUrl = process.env.XUQM_SERVER_URL ?? config.release?.serverUrl const appKey = process.env.XUQM_APP_KEY ?? config.release?.appKey const apiToken = process.env.XUQM_API_TOKEN ?? config.release?.apiToken const androidPropertiesFile = path.join(root, 'android', 'gradle.properties') const bundleFormat = platform === 'android' && existsSync(androidPropertiesFile) && /^\s*hermesEnabled\s*=\s*true\s*$/im.test(readFileSync(androidPropertiesFile, 'utf8')) ? 'hermes-bytecode' : 'javascript' if (!serverUrl || !appKey || !apiToken) { throw new Error( 'publish requires XUQM_SERVER_URL, XUQM_APP_KEY and XUQM_API_TOKEN (or release.* config)', ) } const rl = createInterface({ input: process.stdin, output: process.stdout }) const ask = question => new Promise(resolve => rl.question(`\x1b[36m${question}\x1b[0m`, resolve)) const confirm = async question => /^y/i.test(((await ask(`${question} [y/N]: `)) ?? '').trim()) function apiHeaders() { return { Authorization: `Bearer ${apiToken}` } } async function uploadBundle(module, note, minNativeApiLevel) { const file = path.join( bundleOutputDirectory(root, config, platform, module.id), `${module.id}.${platform}.xuqm.zip`, ) if (!existsSync(file)) throw new Error(`plugin package is missing: ${file}`) const form = new FormData() form.append('appKey', appKey) form.append('moduleId', module.id) form.append('platform', platform.toUpperCase()) form.append('version', module.moduleVersion) form.append('appVersionRange', module.appVersionRange) form.append('builtAgainstNativeBaselineId', nativeBaselineId) form.append('bundleFormat', bundleFormat) form.append('commonVersionRange', module.commonVersionRange ?? '') form.append('minNativeApiLevel', String(module.minNativeApiLevel ?? minNativeApiLevel)) form.append('note', note) if (config.packageName) form.append('packageName', config.packageName) form.append('bundle', new Blob([readFileSync(file)]), `${module.id}.${platform}.xuqm.zip`) const response = await fetch(`${serverUrl}/api/v1/rn/upload`, { method: 'POST', headers: apiHeaders(), body: form, }) if (!response.ok) throw new Error(`upload failed for ${module.id}: ${await response.text()}`) return response.json() } async function main() { const note = noteOption ?? (await ask('Release notes: ')) const minNativeApiLevel = Number( minNativeApiLevelOption ?? config.release?.minNativeApiLevel ?? 2, ) if (!Number.isInteger(minNativeApiLevel) || minNativeApiLevel < 1) { throw new Error('minNativeApiLevel must be a positive integer') } const publishImmediately = publishOption || config.release?.publishImmediately === true console.log('\n\x1b[36m--- Xuqm RN plugin publish ---\x1b[0m') console.log(` Platform: ${platform}`) console.log( ` Modules: ${modules.map(module => `${module.id}@${module.moduleVersion}`).join(', ')}`, ) console.log(` Publish immediately: ${publishImmediately}`) if (!(await confirm('Proceed?'))) return for (const module of modules) { const result = await uploadBundle(module, note, minNativeApiLevel) const bundleId = result.data?.id console.log(`\x1b[32m✓ ${module.id}/${platform} uploaded, ID: ${bundleId}\x1b[0m`) if (publishImmediately) { const response = await fetch(`${serverUrl}/api/v1/rn/${bundleId}/publish`, { method: 'POST', headers: apiHeaders(), }) if (!response.ok) throw new Error(`publish failed for ${module.id}: ${await response.text()}`) console.log(` ${module.id}/${platform} published`) } } } main() .catch(error => { console.error(`\n\x1b[31m${error.message}\x1b[0m`) process.exitCode = 1 }) .finally(() => rl.close())