#!/usr/bin/env node /** * create-plugin.mjs — UpdateSDK 插件脚手架工具 * * 用法: * node scripts/create-plugin.mjs * npx @xuqm/rn-update create-plugin * * 功能: * 1. 交互式输入插件参数(moduleId / title / subtitle / accentColor) * 2. 校验 moduleId 唯一性 * 3. 自动生成完整插件骨架(bundle.ts / Screen / plugin.json) * 4. 自动注册到宿主(pluginCatalog / debugPlugins / build scripts / metro config) */ import fs from 'fs'; import path from 'path'; import readline from 'readline'; // ─── 交互工具 ──────────────────────────────────────────────────────────────── function createRL() { return readline.createInterface({ input: process.stdin, output: process.stdout, }); } function ask(rl, question) { return new Promise(resolve => { rl.question(question, answer => resolve(answer.trim())); }); } // ─── 项目路径检测 ──────────────────────────────────────────────────────────── function findProjectRoot() { let dir = process.cwd(); while (dir !== path.dirname(dir)) { if (fs.existsSync(path.join(dir, 'package.json'))) return dir; dir = path.dirname(dir); } console.error('❌ 未找到 package.json,请在 RN 项目根目录下运行此脚本。'); process.exit(1); } // ─── 唯一性校验 ────────────────────────────────────────────────────────────── function getExistingPluginIds(root) { const ids = new Set(); // 从 pluginCatalog.ts 读取 const catalogPath = path.join(root, 'src/app/pluginCatalog.ts'); if (fs.existsSync(catalogPath)) { const content = fs.readFileSync(catalogPath, 'utf-8'); const matches = content.matchAll(/id:\s*'([^']+)'/g); for (const m of matches) ids.add(m[1]); } // 从 plugins 目录读取 const pluginsDir = path.join(root, 'src/plugins'); if (fs.existsSync(pluginsDir)) { for (const entry of fs.readdirSync(pluginsDir)) { const full = path.join(pluginsDir, entry); if (fs.statSync(full).isDirectory()) ids.add(entry); } } return ids; } // ─── 文件生成 ──────────────────────────────────────────────────────────────── function pascalCase(str) { return str .replace(/[-_]+(\w)/g, (_, c) => c.toUpperCase()) .replace(/^(\w)/, (_, c) => c.toUpperCase()); } function generateBundleTs(moduleId, title, subtitle, accentColor) { const screenName = `${pascalCase(moduleId)}Screen`; return `import { UpdateSDK } from '@xuqm/rn-update'; import { registerPluginFromBridge } from '@plugins/runtimeBridge'; import { ${screenName} } from '@${moduleId}/${screenName}'; // 自动注册插件版本(bundle 加载时执行) UpdateSDK.registerPlugin({ moduleId: '${moduleId}', version: '1.0.0' }); // 注册 UI 组件到宿主运行时 registerPluginFromBridge({ id: '${moduleId}', title: '${title}', subtitle: '${subtitle}', accentColor: '${accentColor}', Component: ${screenName}, }); `; } function generateScreenTs(moduleId) { const screenName = `${pascalCase(moduleId)}Screen`; return `import { StyleSheet, Text, View } from 'react-native'; export function ${screenName}() { return ( ${moduleId} TODO: 实现${moduleId}功能 ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F5F7FA' }, title: { fontSize: 24, fontWeight: '700', color: '#17171A' }, subtitle: { fontSize: 14, color: '#666666', marginTop: 8 }, }); `; } function generatePluginJson(moduleId) { return JSON.stringify({ moduleId, version: '1.0.0' }, null, 2) + '\n'; } // ─── 宿主文件更新 ──────────────────────────────────────────────────────────── function updatePluginCatalog(root, moduleId, title, accentColor) { const filePath = path.join(root, 'src/app/pluginCatalog.ts'); if (!fs.existsSync(filePath)) { console.warn('⚠️ src/app/pluginCatalog.ts 不存在,跳过'); return; } let content = fs.readFileSync(filePath, 'utf-8'); // 检查是否已存在 if (content.includes(`id: '${moduleId}'`)) { console.log(` ℹ️ pluginCatalog 已包含 ${moduleId},跳过`); return; } const newEntry = ` { id: '${moduleId}', title: '${title}', summary: '${title}业务模块', description: '点击后异步加载 ${moduleId} bundle。', accentColor: '${accentColor}', },`; // 在最后一个 ] 之前插入 const lastBracket = content.lastIndexOf('];'); if (lastBracket === -1) { console.warn('⚠️ pluginCatalog.ts 格式异常,跳过'); return; } content = content.slice(0, lastBracket) + newEntry + '\n' + content.slice(lastBracket); fs.writeFileSync(filePath, content, 'utf-8'); } function updateDebugPlugins(root, moduleId) { const filePath = path.join(root, 'src/bootstrap/debugPlugins.ts'); if (!fs.existsSync(filePath)) { console.warn('⚠️ src/bootstrap/debugPlugins.ts 不存在,跳过'); return; } let content = fs.readFileSync(filePath, 'utf-8'); if (content.includes(`'${moduleId}'`)) { console.log(` ℹ️ debugPlugins 已包含 ${moduleId},跳过`); return; } const newLoader = ` registerDebugPluginLoader('${moduleId}', async () => { require('../plugins/${moduleId}/bundle'); });`; content = content.trimEnd() + newLoader + '\n'; fs.writeFileSync(filePath, content, 'utf-8'); } function updatePackageJson(root, moduleId) { const filePath = path.join(root, 'package.json'); const pkg = JSON.parse(fs.readFileSync(filePath, 'utf-8')); const androidKey = `build:android:${moduleId}`; const iosKey = `build:ios:${moduleId}`; if (pkg.scripts[androidKey]) { console.log(` ℹ️ package.json 已包含 ${androidKey},跳过`); return; } // 添加构建脚本 pkg.scripts[androidKey] = `mkdir -p bundle/android/${moduleId} && react-native bundle --platform android --dev false --entry-file ./src/plugins/${moduleId}/bundle.ts --bundle-output ./bundle/android/${moduleId}/${moduleId}.android.bundle --assets-dest ./bundle/android/${moduleId} --config metro.split.config.js --reset-cache`; pkg.scripts[iosKey] = `mkdir -p bundle/ios/${moduleId} && react-native bundle --platform ios --dev false --entry-file ./src/plugins/${moduleId}/bundle.ts --bundle-output ./bundle/ios/${moduleId}/${moduleId}.ios.bundle --assets-dest ./bundle/ios/${moduleId} --config metro.split.config.js --reset-cache`; // 更新 plugins 聚合脚本 for (const platform of ['android', 'ios']) { const key = `build:${platform}:plugins`; if (pkg.scripts[key] && !pkg.scripts[key].includes(`${platform}:${moduleId}`)) { pkg.scripts[key] = pkg.scripts[key] + ` && yarn build:${platform}:${moduleId}`; } } // 更新 embedded 脚本 for (const platform of ['android', 'ios']) { const key = `build:${platform}:embedded`; if (pkg.scripts[key] && !pkg.scripts[key].includes(`${platform}:${moduleId}`)) { // 在 prepare-embedded-bundles 之前插入 pkg.scripts[key] = pkg.scripts[key].replace( / && node \.\/scripts\/prepare/, ` && yarn build:${platform}:${moduleId} && node ./scripts/prepare`, ); } } fs.writeFileSync(filePath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8'); } function updateMetroConfig(root, moduleId) { const filePath = path.join(root, 'metro.split.config.js'); if (!fs.existsSync(filePath)) { console.warn('⚠️ metro.split.config.js 不存在,跳过'); return; } let content = fs.readFileSync(filePath, 'utf-8'); if (content.includes(`'${moduleId}'`)) { console.log(` ℹ️ metro.split.config 已包含 ${moduleId},跳过`); return; } // 计算 offset:buz1=11M, buz2=12M, buzN=(10+N)M const num = parseInt(moduleId.replace(/\D/g, ''), 10); const offset = Number.isFinite(num) ? (10 + num) * 1_000_000 : 30_000_000; // 在 inferOffset 函数中添加新插件的分支 const insertPoint = content.lastIndexOf('return MODULE_OFFSETS'); if (insertPoint === -1) { console.warn('⚠️ metro.split.config.js 格式异常,跳过'); return; } const newBranch = ` if (modulePath.includes(\`\${path.sep}${moduleId}\${path.sep}\`)) { return ${offset}; } `; content = content.slice(0, insertPoint) + newBranch + content.slice(insertPoint); fs.writeFileSync(filePath, content, 'utf-8'); } function updateBabelAlias(root, moduleId) { const filePath = path.join(root, 'babel.config.js'); if (!fs.existsSync(filePath)) return; let content = fs.readFileSync(filePath, 'utf-8'); if (content.includes(`'@${moduleId}'`)) { console.log(` ℹ️ babel alias 已包含 @${moduleId},跳过`); return; } // 在 alias 对象最后一个条目后添加 content = content.replace( /('@utils':\s*'[^']+',?\s*)}/, `$1'@${moduleId}': './src/plugins/${moduleId}',\n }}`, ); fs.writeFileSync(filePath, content, 'utf-8'); } function updateTsconfig(root, moduleId) { const filePath = path.join(root, 'tsconfig.json'); if (!fs.existsSync(filePath)) return; let content = fs.readFileSync(filePath, 'utf-8'); if (content.includes(`"@${moduleId}/*"`)) { console.log(` ℹ️ tsconfig 已包含 @${moduleId},跳过`); return; } content = content.replace( /("@utils\/\*":\s*\["src\/utils\/\*"\],?)/, `$1\n "@${moduleId}/*": ["src/plugins/${moduleId}/*"]`, ); fs.writeFileSync(filePath, content, 'utf-8'); } // ─── 主流程 ────────────────────────────────────────────────────────────────── function printUsage() { console.log(''); console.log('用法:'); console.log(' 交互模式: node scripts/create-plugin.mjs'); console.log(' 命令行: node scripts/create-plugin.mjs [title] [subtitle] [accentColor]'); console.log(''); console.log('示例:'); console.log(' node scripts/create-plugin.mjs buz4 "IM 消息" "即时通讯业务组件" "#E74C3C"'); console.log(' node scripts/create-plugin.mjs buz5'); console.log(''); } async function main() { console.log(''); console.log('╔══════════════════════════════════════════╗'); console.log('║ XuqmGroup UpdateSDK — 插件脚手架工具 ║'); console.log('╚══════════════════════════════════════════╝'); const root = findProjectRoot(); const args = process.argv.slice(2); // ── 解析参数(支持 CLI 和交互两种模式)── let moduleId, title, subtitle, accentColor; if (args.length > 0) { // CLI 模式 [moduleId, title, subtitle, accentColor] = args; } else { // 交互模式 const rl = createRL(); try { moduleId = await ask(rl, '\n插件 ID(如 buz4): '); title = await ask(rl, '插件标题(如 IM 消息): '); subtitle = await ask(rl, '插件副标题(如 即时通讯业务组件): '); accentColor = await ask(rl, '主题色(如 #0E84FA): '); } finally { rl.close(); } } // ── 校验 ── if (!moduleId || !/^[a-z][a-z0-9]*$/.test(moduleId)) { console.error('\n❌ moduleId 必须以小写字母开头,只包含小写字母和数字'); printUsage(); process.exit(1); } title = title || moduleId; subtitle = subtitle || `${title}业务组件`; accentColor = accentColor || '#0E84FA'; // 唯一性校验 const existing = getExistingPluginIds(root); if (existing.has(moduleId)) { console.error(`\n❌ moduleId "${moduleId}" 已存在,请使用其他 ID`); process.exit(1); } console.log(''); console.log(`📦 创建插件: ${moduleId}`); console.log(` 标题: ${title}`); console.log(` 副标题: ${subtitle}`); console.log(` 主题色: ${accentColor}`); console.log(''); // ── 创建目录 ── const pluginDir = path.join(root, 'src/plugins', moduleId); fs.mkdirSync(pluginDir, { recursive: true }); // ── 生成文件 ── const files = [ { path: path.join(pluginDir, 'bundle.ts'), content: generateBundleTs(moduleId, title, subtitle, accentColor), desc: 'bundle 入口(自动注册 UpdateSDK + pluginRuntime)', }, { path: path.join(pluginDir, `${pascalCase(moduleId)}Screen.tsx`), content: generateScreenTs(moduleId), desc: 'Screen 骨架', }, { path: path.join(pluginDir, 'plugin.json'), content: generatePluginJson(moduleId), desc: '插件元数据', }, ]; for (const file of files) { fs.writeFileSync(file.path, file.content, 'utf-8'); console.log(` ✅ ${path.relative(root, file.path)} — ${file.desc}`); } // ── 更新宿主配置 ── console.log(''); console.log('🔗 更新宿主配置:'); updatePluginCatalog(root, moduleId, title, accentColor); console.log(' ✅ pluginCatalog.ts'); updateDebugPlugins(root, moduleId); console.log(' ✅ debugPlugins.ts'); updatePackageJson(root, moduleId); console.log(' ✅ package.json(构建脚本)'); updateMetroConfig(root, moduleId); console.log(' ✅ metro.split.config.js'); updateBabelAlias(root, moduleId); console.log(' ✅ babel.config.js'); updateTsconfig(root, moduleId); console.log(' ✅ tsconfig.json'); // ── 完成 ── console.log(''); console.log('═══════════════════════════════════════════'); console.log(`✅ 插件 ${moduleId} 创建完成!`); console.log(''); console.log('下一步:'); console.log(` 1. 编辑 src/plugins/${moduleId}/${pascalCase(moduleId)}Screen.tsx 实现业务 UI`); console.log(` 2. 在 src/plugins/${moduleId}/ 下创建 services/、pages/ 等目录`); console.log(` 3. yarn validate 确认无错误`); console.log(` 4. yarn build:android:${moduleId} 构建 Android bundle`); console.log('═══════════════════════════════════════════'); console.log(''); } main();