428 行
15 KiB
JavaScript
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 计算 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 <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();
|