#!/usr/bin/env node /** * XuqmGroup Update Service — React Native Bundle Release Script * * Platform-native approach: runs via Node.js (always available in RN projects). * Add to your app's package.json scripts: * "xuqm:release": "node node_modules/@xuqm/update/scripts/xuqm_release.mjs" * * Config: xuqm.config.json in the project root * { * "serverUrl": "https://update.dev.xuqinmin.com", * "appId": "your-app-id", * "apiToken": "your-api-token", * "rn": { * "modules": [ * { "moduleId": "main", "entryFile": "index.js", "platforms": ["android", "ios"] } * ], * "bundleOutputDir": "/tmp/xuqm_rn_bundle" * } * } * * Dependencies: none beyond Node.js built-ins (uses native fetch + node:child_process) * Minimum Node.js: 18 (built-in fetch) */ import { execSync } from 'node:child_process' import { createReadStream, mkdirSync, existsSync, readFileSync } from 'node:fs' import { createInterface } from 'node:readline' import path from 'node:path' // ── Config ───────────────────────────────────────────────────────────────── const CONFIG_FILE = process.env.XUQM_CONFIG_FILE ?? 'xuqm.config.json' if (!existsSync(CONFIG_FILE)) { console.error(`[xuqm] Config not found: ${CONFIG_FILE}`) process.exit(1) } const cfg = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) const { serverUrl, appId, apiToken, rn = {} } = cfg if (!serverUrl || !appId || !apiToken) { console.error('[xuqm] serverUrl / appId / apiToken are required in config') process.exit(1) } const modules = rn.modules ?? [{ moduleId: 'main', entryFile: 'index.js', platforms: ['android', 'ios'] }] const bundleOutputDir = rn.bundleOutputDir ?? '/tmp/xuqm_rn_bundle' // ── Helpers ───────────────────────────────────────────────────────────────── const rl = createInterface({ input: process.stdin, output: process.stdout }) const ask = (q) => new Promise(res => rl.question(`\x1b[36m${q}\x1b[0m`, res)) const confirm = async (q) => /^y/i.test(((await ask(`${q} [y/N]: `)) ?? '').trim()) function apiHeaders() { return { Authorization: `Bearer ${apiToken}` } } async function apiFetch(path, opts = {}) { const res = await fetch(`${serverUrl}${path}`, { headers: apiHeaders(), ...opts }) if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`) return res.json() } // Multipart upload using FormData (Node 18+ has built-in FormData) async function uploadBundle(moduleId, platform, bundleFile, version, minVersion, note) { const form = new FormData() const blob = new Blob([readFileSync(bundleFile)], { type: 'application/octet-stream' }) form.append('appId', appId) form.append('moduleId', moduleId) form.append('platform', platform.toUpperCase()) form.append('version', version) form.append('minCommonVersion', minVersion) form.append('note', note) form.append('bundle', blob, path.basename(bundleFile)) const res = await fetch(`${serverUrl}/api/v1/rn/upload`, { method: 'POST', headers: apiHeaders(), body: form, }) if (!res.ok) throw new Error(`Upload failed: ${await res.text()}`) return res.json() } // ── Main ───────────────────────────────────────────────────────────────────── async function main() { console.log('\x1b[36m=== XuqmGroup React Native Release ===\x1b[0m\n') // ── 1. Local version from package.json ────────────────────────────────── const pkg = JSON.parse(readFileSync('package.json', 'utf8')) let localVersion = pkg.version ?? '' console.log(`Local version (package.json): \x1b[32m${localVersion}\x1b[0m`) // ── 2. Server latest ───────────────────────────────────────────────────── let serverVersion = 'none' try { const resp = await apiFetch(`/api/v1/rn/list?appId=${appId}`) const published = (resp.data ?? []).filter(x => x.publishStatus === 'PUBLISHED') serverVersion = published[0]?.version ?? 'none' } catch { /* no bundles yet */ } console.log(`Server latest: \x1b[33m${serverVersion}\x1b[0m`) // ── 3. Validate version ─────────────────────────────────────────────────── if (localVersion && serverVersion !== 'none' && localVersion <= serverVersion) { console.warn(`\x1b[33m⚠ Local (${localVersion}) ≤ server (${serverVersion})\x1b[0m`) const cont = await confirm('Continue anyway?') if (!cont) { localVersion = await ask('New version string: ') } } if (!localVersion) localVersion = await ask('Version string (e.g. 1.2.3): ') // ── 4. Options ──────────────────────────────────────────────────────────── const note = await ask('Release notes: ') const minVersion = await ask('Min compatible native version (e.g. 1.0.0): ') console.log('\n\x1b[36m--- Summary ---\x1b[0m') console.log(` Version: ${localVersion}`) console.log(` Modules: ${modules.map(m => m.moduleId).join(', ')}`) const ok = await confirm('Proceed?') if (!ok) { console.log('Aborted.'); rl.close(); return } // ── 5. Build & upload each module/platform ──────────────────────────────── mkdirSync(bundleOutputDir, { recursive: true }) for (const mod of modules) { for (const platform of mod.platforms) { const outDir = path.join(bundleOutputDir, mod.moduleId, platform) mkdirSync(outDir, { recursive: true }) const bundleFile = path.join(outDir, `${mod.moduleId}.${platform}.bundle`) // Build using react-native CLI (platform-native, no extra deps) console.log(`\n\x1b[36mBuilding ${mod.moduleId}/${platform}...\x1b[0m`) execSync( `npx react-native bundle \ --platform ${platform} \ --entry-file ${mod.entryFile ?? 'index.js'} \ --bundle-output ${bundleFile} \ --dev false \ --minify true`, { stdio: 'inherit' } ) // Upload console.log('\x1b[36mUploading...\x1b[0m') const resp = await uploadBundle(mod.moduleId, platform, bundleFile, localVersion, minVersion, note) const bundleId = resp.data?.id console.log(`\x1b[32m✓ ${mod.moduleId}/${platform} uploaded, ID: ${bundleId}\x1b[0m`) console.log(` Publish: POST ${serverUrl}/api/v1/rn/${bundleId}/publish`) } } rl.close() console.log('\n\x1b[32m=== Release complete ===\x1b[0m') } main().catch(e => { console.error(`\n\x1b[31m❌ ${e.message}\x1b[0m`) rl.close() process.exit(1) })