muspe-cli
Version:
MusPE Advanced Framework v2.1.3 - Mobile User-friendly Simple Progressive Engine with Enhanced CLI Tools, Specialized E-Commerce Templates, Material Design 3, Progressive Enhancement, Mobile Optimizations, Performance Analysis, and Enterprise-Grade Develo
562 lines (457 loc) ⢠18.8 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
const chalk = require('chalk');
const ora = require('ora');
const spawn = require('cross-spawn');
const yaml = require('js-yaml');
const plist = require('plist');
// Enhanced iOS Build System for MusPE
class IOSBuilder {
constructor(projectRoot, config) {
this.projectRoot = projectRoot;
this.config = config;
this.platformsDir = path.join(projectRoot, 'platforms', 'ios');
this.buildConfig = this.loadBuildConfig();
}
loadBuildConfig() {
try {
const configPath = path.join(this.projectRoot, 'config', 'mobile-build.yml');
if (fs.existsSync(configPath)) {
const configFile = fs.readFileSync(configPath, 'utf8');
return yaml.load(configFile);
}
} catch (error) {
console.warn(chalk.yellow('Warning: Could not load mobile build config, using defaults'));
}
return this.getDefaultConfig();
}
getDefaultConfig() {
return {
ios: {
bundleId: 'com.muspe.app',
version: '1.0.0',
buildNumber: 1,
deploymentTarget: '12.0',
configuration: 'Release'
}
};
}
async build(options = {}) {
// Check if running on macOS
if (process.platform !== 'darwin') {
throw new Error('iOS builds can only be performed on macOS');
}
const spinner = ora('Building iOS application...').start();
try {
// Check Xcode installation
await this.checkXcodeInstallation();
// Ensure Cordova platform is added
await this.ensurePlatform();
// Configure build settings
await this.configureBuild();
// Copy web assets
await this.copyWebAssets();
// Configure iOS-specific files
await this.configureIOSFiles();
// Build the app
const buildType = options.release ? 'release' : 'debug';
await this.buildApp(buildType, options);
spinner.succeed('iOS build completed successfully');
// Display build information
await this.displayBuildInfo(buildType);
} catch (error) {
spinner.fail('iOS build failed');
throw error;
}
}
async checkXcodeInstallation() {
try {
await this.runCommand('xcodebuild', ['-version']);
} catch (error) {
throw new Error('Xcode is not installed or not properly configured. Please install Xcode from the App Store.');
}
}
async ensurePlatform() {
if (!await fs.pathExists(this.platformsDir)) {
console.log(chalk.blue('Adding iOS platform...'));
await this.runCordovaCommand('platform', ['add', 'ios']);
}
}
async configureBuild() {
// Configure Info.plist
await this.configureInfoPlist();
// Configure build settings
await this.configureXcodeProject();
// Configure entitlements
await this.configureEntitlements();
// Configure provisioning profiles (if provided)
await this.configureCodeSigning();
}
async configureInfoPlist() {
const appName = this.getAppName();
const plistPath = path.join(this.platformsDir, appName, `${appName}-Info.plist`);
if (await fs.pathExists(plistPath)) {
const plistContent = plist.parse(await fs.readFile(plistPath, 'utf8'));
// Update bundle identifier
plistContent.CFBundleIdentifier = this.buildConfig.ios.bundleId;
// Update version information
plistContent.CFBundleShortVersionString = this.buildConfig.ios.version;
plistContent.CFBundleVersion = this.buildConfig.ios.buildNumber.toString();
// Update app name
plistContent.CFBundleDisplayName = this.config.name || 'MusPE App';
plistContent.CFBundleName = this.config.name || 'MusPE App';
// Add usage descriptions
if (this.buildConfig.ios.plist) {
Object.keys(this.buildConfig.ios.plist).forEach(key => {
plistContent[key] = this.buildConfig.ios.plist[key];
});
}
// Configure supported orientations
plistContent.UISupportedInterfaceOrientations = [
'UIInterfaceOrientationPortrait',
'UIInterfaceOrientationLandscapeLeft',
'UIInterfaceOrientationLandscapeRight'
];
plistContent['UISupportedInterfaceOrientations~ipad'] = [
'UIInterfaceOrientationPortrait',
'UIInterfaceOrientationLandscapeLeft',
'UIInterfaceOrientationLandscapeRight',
'UIInterfaceOrientationPortraitUpsideDown'
];
// Configure status bar
plistContent.UIStatusBarHidden = false;
plistContent.UIViewControllerBasedStatusBarAppearance = false;
// Save updated plist
const updatedPlist = plist.build(plistContent);
await fs.writeFile(plistPath, updatedPlist);
}
}
async configureXcodeProject() {
const appName = this.getAppName();
const projectPath = path.join(this.platformsDir, `${appName}.xcodeproj`, 'project.pbxproj');
if (await fs.pathExists(projectPath)) {
let projectContent = await fs.readFile(projectPath, 'utf8');
// Update deployment target
projectContent = projectContent.replace(
/IPHONEOS_DEPLOYMENT_TARGET = [^;]+;/g,
`IPHONEOS_DEPLOYMENT_TARGET = ${this.buildConfig.ios.deploymentTarget};`
);
// Update bundle identifier
projectContent = projectContent.replace(
/PRODUCT_BUNDLE_IDENTIFIER = [^;]+;/g,
`PRODUCT_BUNDLE_IDENTIFIER = ${this.buildConfig.ios.bundleId};`
);
// Update marketing version
projectContent = projectContent.replace(
/MARKETING_VERSION = [^;]+;/g,
`MARKETING_VERSION = ${this.buildConfig.ios.version};`
);
// Update current project version
projectContent = projectContent.replace(
/CURRENT_PROJECT_VERSION = [^;]+;/g,
`CURRENT_PROJECT_VERSION = ${this.buildConfig.ios.buildNumber};`
);
// Configure code signing (if specified)
if (this.buildConfig.ios.codeSign) {
projectContent = projectContent.replace(
/CODE_SIGN_IDENTITY = [^;]+;/g,
`CODE_SIGN_IDENTITY = "${this.buildConfig.ios.codeSign.identity}";`
);
if (this.buildConfig.ios.codeSign.profile) {
projectContent = projectContent.replace(
/PROVISIONING_PROFILE_SPECIFIER = [^;]+;/g,
`PROVISIONING_PROFILE_SPECIFIER = "${this.buildConfig.ios.codeSign.profile}";`
);
}
if (this.buildConfig.ios.codeSign.team) {
projectContent = projectContent.replace(
/DEVELOPMENT_TEAM = [^;]+;/g,
`DEVELOPMENT_TEAM = ${this.buildConfig.ios.codeSign.team};`
);
}
}
await fs.writeFile(projectPath, projectContent);
}
}
async configureEntitlements() {
if (!this.buildConfig.ios.capabilities) return;
const appName = this.getAppName();
const entitlementsPath = path.join(this.platformsDir, appName, `${appName}.entitlements`);
const entitlements = {
'com.apple.developer.team-identifier': this.buildConfig.ios.codeSign?.team || 'TEAM_ID'
};
// Add capabilities
this.buildConfig.ios.capabilities.forEach(capability => {
switch (capability) {
case 'com.apple.developer.networking.wifi-info':
entitlements['com.apple.developer.networking.wifi-info'] = true;
break;
case 'com.apple.developer.location.services':
entitlements['com.apple.security.location'] = true;
break;
// Add more capabilities as needed
}
});
const entitlementsXml = plist.build(entitlements);
await fs.writeFile(entitlementsPath, entitlementsXml);
}
async configureCodeSigning() {
// This would involve configuring provisioning profiles
// In a real implementation, you'd need to:
// 1. Download provisioning profiles from Apple Developer
// 2. Install certificates in keychain
// 3. Configure Xcode project with correct signing settings
console.log(chalk.blue('Code signing configuration...'));
if (this.buildConfig.ios.codeSign?.team) {
console.log(chalk.gray(`Development Team: ${this.buildConfig.ios.codeSign.team}`));
}
if (this.buildConfig.ios.codeSign?.profile) {
console.log(chalk.gray(`Provisioning Profile: ${this.buildConfig.ios.codeSign.profile}`));
}
}
async copyWebAssets() {
const webAssetsPath = path.join(this.projectRoot, 'www');
const iosAssetsPath = path.join(this.platformsDir, 'www');
if (await fs.pathExists(webAssetsPath)) {
await fs.copy(webAssetsPath, iosAssetsPath);
}
}
async configureIOSFiles() {
// Configure app icons
await this.configureIcons();
// Configure launch screens
await this.configureLaunchScreens();
// Configure splash screens
await this.configureSplashScreens();
}
async configureIcons() {
const iconsDir = path.join(this.projectRoot, 'assets', 'icons');
const appName = this.getAppName();
const iosIconsDir = path.join(this.platformsDir, appName, 'Images.xcassets', 'AppIcon.appiconset');
if (await fs.pathExists(iconsDir) && await fs.pathExists(iosIconsDir)) {
// iOS icon sizes and their corresponding files
const iconSizes = [
{ size: '20x20', scale: '2x', filename: 'icon-20@2x.png' },
{ size: '20x20', scale: '3x', filename: 'icon-20@3x.png' },
{ size: '29x29', scale: '2x', filename: 'icon-29@2x.png' },
{ size: '29x29', scale: '3x', filename: 'icon-29@3x.png' },
{ size: '40x40', scale: '2x', filename: 'icon-40@2x.png' },
{ size: '40x40', scale: '3x', filename: 'icon-40@3x.png' },
{ size: '60x60', scale: '2x', filename: 'icon-60@2x.png' },
{ size: '60x60', scale: '3x', filename: 'icon-60@3x.png' },
{ size: '76x76', scale: '1x', filename: 'icon-76.png' },
{ size: '76x76', scale: '2x', filename: 'icon-76@2x.png' },
{ size: '83.5x83.5', scale: '2x', filename: 'icon-83.5@2x.png' },
{ size: '1024x1024', scale: '1x', filename: 'icon-1024.png' }
];
// Copy icon files
for (const icon of iconSizes) {
const iconSrc = path.join(iconsDir, icon.filename);
const iconDest = path.join(iosIconsDir, icon.filename);
if (await fs.pathExists(iconSrc)) {
await fs.copy(iconSrc, iconDest);
}
}
// Generate Contents.json for icon set
const contentsJson = {
images: iconSizes.map(icon => ({
size: icon.size,
idiom: icon.size.includes('76') || icon.size.includes('83.5') ? 'ipad' : 'iphone',
filename: icon.filename,
scale: icon.scale
})),
info: {
version: 1,
author: 'muspe'
}
};
await fs.writeJSON(path.join(iosIconsDir, 'Contents.json'), contentsJson, { spaces: 2 });
}
}
async configureLaunchScreens() {
const appName = this.getAppName();
const launchScreenPath = path.join(this.platformsDir, appName, 'CDVLaunchScreen.storyboard');
if (await fs.pathExists(launchScreenPath)) {
// Configure launch screen storyboard
let storyboard = await fs.readFile(launchScreenPath, 'utf8');
// Update app name in launch screen
storyboard = storyboard.replace(
/text="[^"]*"/g,
`text="${this.config.name || 'MusPE App'}"`
);
await fs.writeFile(launchScreenPath, storyboard);
}
}
async configureSplashScreens() {
const splashDir = path.join(this.projectRoot, 'assets', 'splash');
const appName = this.getAppName();
const iosSplashDir = path.join(this.platformsDir, appName, 'Images.xcassets', 'LaunchImage.launchimage');
if (await fs.pathExists(splashDir) && await fs.pathExists(iosSplashDir)) {
// iOS splash screen configurations
const splashConfigs = [
{ filename: 'Default@2x~iphone.png', width: 640, height: 960 },
{ filename: 'Default-568h@2x~iphone.png', width: 640, height: 1136 },
{ filename: 'Default-667h.png', width: 750, height: 1334 },
{ filename: 'Default-736h.png', width: 1242, height: 2208 },
{ filename: 'Default-Landscape-736h.png', width: 2208, height: 1242 },
{ filename: 'Default-Portrait~ipad.png', width: 768, height: 1024 },
{ filename: 'Default-Landscape~ipad.png', width: 1024, height: 768 },
{ filename: 'Default-Portrait@2x~ipad.png', width: 1536, height: 2048 },
{ filename: 'Default-Landscape@2x~ipad.png', width: 2048, height: 1536 }
];
// Copy splash screen files
for (const splash of splashConfigs) {
const splashSrc = path.join(splashDir, splash.filename);
const splashDest = path.join(iosSplashDir, splash.filename);
if (await fs.pathExists(splashSrc)) {
await fs.copy(splashSrc, splashDest);
}
}
}
}
async buildApp(buildType, options) {
const configuration = buildType === 'release' ? 'Release' : 'Debug';
const buildArgs = ['--configuration', configuration];
if (options.device) {
buildArgs.push('--device');
} else if (options.simulator) {
buildArgs.push('--emulator');
}
if (options.verbose) {
buildArgs.push('--verbose');
}
await this.runCordovaCommand('build', ['ios', ...buildArgs]);
// For release builds, also create an archive
if (buildType === 'release' && options.archive) {
await this.createArchive();
}
}
async createArchive() {
const appName = this.getAppName();
const workspacePath = path.join(this.platformsDir, `${appName}.xcworkspace`);
const archivePath = path.join(this.platformsDir, 'build', `${appName}.xcarchive`);
console.log(chalk.blue('Creating Xcode archive...'));
const archiveArgs = [
'-workspace', workspacePath,
'-scheme', appName,
'-configuration', 'Release',
'-archivePath', archivePath,
'archive'
];
await this.runCommand('xcodebuild', archiveArgs);
console.log(chalk.green(`Archive created: ${archivePath}`));
}
async exportIPA(archivePath, exportPath, exportMethod = 'development') {
// Create export options plist
const exportOptions = {
method: exportMethod, // development, ad-hoc, app-store, enterprise
uploadBitcode: false,
uploadSymbols: true,
compileBitcode: false
};
if (this.buildConfig.ios.codeSign?.team) {
exportOptions.teamID = this.buildConfig.ios.codeSign.team;
}
const exportOptionsPath = path.join(this.platformsDir, 'ExportOptions.plist');
const exportOptionsPlist = plist.build(exportOptions);
await fs.writeFile(exportOptionsPath, exportOptionsPlist);
// Export IPA
const exportArgs = [
'-exportArchive',
'-archivePath', archivePath,
'-exportPath', exportPath,
'-exportOptionsPlist', exportOptionsPath
];
await this.runCommand('xcodebuild', exportArgs);
}
async displayBuildInfo(buildType) {
console.log(chalk.green('\n⨠iOS build completed!'));
console.log(chalk.cyan('\nš± Build artifacts:'));
const appName = this.getAppName();
const buildPath = path.join(this.platformsDir, 'build');
if (await fs.pathExists(buildPath)) {
console.log(` ${chalk.gray('Build Path:')} ${buildPath}`);
const appPath = path.join(buildPath, buildType + '-iphoneos', `${appName}.app`);
if (await fs.pathExists(appPath)) {
console.log(` ${chalk.gray('App Bundle:')} ${appPath}`);
}
const archivePath = path.join(buildPath, `${appName}.xcarchive`);
if (await fs.pathExists(archivePath)) {
console.log(` ${chalk.gray('Archive:')} ${archivePath}`);
}
}
console.log(chalk.cyan('\nš Next steps:'));
console.log(` ${chalk.gray('Simulator:')} muspe cordova emulate ios`);
console.log(` ${chalk.gray('Device:')} muspe cordova run ios --device`);
if (buildType === 'release') {
console.log(` ${chalk.gray('Archive:')} Open Xcode ā Window ā Organizer`);
console.log(` ${chalk.gray('Upload:')} Upload to App Store Connect`);
}
}
getAppName() {
return this.config.name?.replace(/[^a-zA-Z0-9]/g, '') || 'MusPEApp';
}
async runCordovaCommand(command, args) {
return new Promise((resolve, reject) => {
const child = spawn('cordova', [command, ...args], {
cwd: this.projectRoot,
stdio: 'inherit'
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Cordova command failed with code ${code}`));
}
});
child.on('error', reject);
});
}
async runCommand(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: 'pipe',
...options
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Command ${command} failed with code ${code}`));
}
});
child.on('error', reject);
});
}
// App Store Connect integration
async uploadToAppStore(ipaPath, options = {}) {
// This would use Transporter CLI or altool for uploading to App Store Connect
console.log(chalk.blue('App Store upload functionality would be implemented here'));
console.log(chalk.gray('Manual upload: Open Xcode ā Window ā Organizer ā Distribute App'));
// Example using altool (deprecated but still functional):
// xcrun altool --upload-app --type ios --file ${ipaPath} --username ${appleId} --password ${appSpecificPassword}
// New method using Transporter:
// xcrun iTMSTransporter -m upload -f ${ipaPath} -u ${appleId} -p ${appSpecificPassword}
}
async validateBuild(ipaPath) {
// Validate the IPA before uploading
const validateArgs = [
'altool',
'--validate-app',
'--type', 'ios',
'--file', ipaPath
];
if (process.env.APPLE_ID && process.env.APP_SPECIFIC_PASSWORD) {
validateArgs.push('--username', process.env.APPLE_ID);
validateArgs.push('--password', process.env.APP_SPECIFIC_PASSWORD);
}
try {
await this.runCommand('xcrun', validateArgs);
console.log(chalk.green('ā
IPA validation successful'));
} catch (error) {
console.error(chalk.red('ā IPA validation failed'));
throw error;
}
}
}
module.exports = { IOSBuilder };