XuqmGroup-RNSDK/packages/update/scripts/create-plugin.mjs

428 行
15 KiB
JavaScript

#!/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 (
<View style={styles.container}>
<Text style={styles.title}>${moduleId}</Text>
<Text style={styles.subtitle}>TODO: 实现${moduleId}功能</Text>
</View>
);
}
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;
}
// 计算 offsetbuz1=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 <moduleId> [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();