UNPKG

rock

Version:

Command-line interface for Rock - a React Native development toolkit

380 lines 17.3 kB
import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { color, colorLink, formatArtifactName, getInfoPlist, getLocalArtifactPath, getLocalBinaryPath, handleDownloadResponse, handleUploadResponse, logger, relativeToCwd, RockError, spawn, spinner, } from '@rock-js/tools'; import AdmZip from 'adm-zip'; import * as tar from 'tar'; import { templateIndexHtmlAndroid, templateIndexHtmlIOS, templateManifestPlist, } from '../adHocTemplates.js'; async function remoteCache({ action, args, remoteCacheProvider, projectRoot, fingerprintOptions, }) { const isJsonOutput = args.json; if (!remoteCacheProvider) { return null; } const remoteBuildCache = remoteCacheProvider(); validateArgs(args, action); const artifactName = args.name ?? (await formatArtifactName({ platform: args.platform, traits: args.traits, root: projectRoot, fingerprintOptions, raw: isJsonOutput, })); switch (action) { case 'list': { const artifacts = await remoteBuildCache.list({ artifactName, limit: args.all ? undefined : 1, }); if (artifacts.length > 0 && !args.all) { const artifact = artifacts[0]; if (isJsonOutput) { console.log(JSON.stringify(artifact, null, 2)); } else { logger.log(`Artifact information: - name: ${color.bold(color.blue(artifact.name))} - url: ${colorLink(artifact.url)}`); } } else if (artifacts.length > 0 && args.all) { if (isJsonOutput) { console.log(JSON.stringify(artifacts, null, 2)); } else { artifacts.forEach((artifact) => { logger.log(`Artifact information: - name: ${color.bold(color.blue(artifact.name))} - url: ${colorLink(artifact.url)}`); }); } } break; } case 'list-all': { const artifactName = undefined; const artifacts = await remoteBuildCache.list({ artifactName }); const platform = args.platform; const traits = args.traits; const output = platform && traits ? artifacts.filter((artifact) => artifact.name.startsWith(`rock-${platform}-${traits.join('-')}`)) : artifacts; if (isJsonOutput) { console.log(JSON.stringify(output, null, 2)); } else { logger.log(`Artifacts: ${output .map((artifact) => `- name: ${color.bold(color.blue(artifact.name))}\n- url: ${colorLink(artifact.url)}`) .join('\n')} `); } break; } case 'download': { const localArtifactPath = getLocalArtifactPath(artifactName); const response = await remoteBuildCache.download({ artifactName }); const loader = spinner({ silent: isJsonOutput }); loader.start(`Downloading cached build from ${color.bold(remoteBuildCache.name)}`); await handleDownloadResponse(response, localArtifactPath, (progress, totalMB) => { loader.message(`Downloading cached build from ${color.bold(remoteBuildCache.name)} (${progress}% of ${totalMB} MB)`); }); const binaryPath = getLocalBinaryPath(localArtifactPath); loader.stop(`Downloaded cached build from ${color.bold(remoteBuildCache.name)}`); if (!binaryPath) { throw new RockError(`Failed to save binary for "${artifactName}".`); } if (isJsonOutput) { console.log(JSON.stringify({ name: artifactName, path: binaryPath }, null, 2)); } else { logger.log(`Artifact information: - name: ${color.bold(color.blue(artifactName))} - path: ${colorLink(relativeToCwd(binaryPath))}`); } break; } case 'upload': { const localArtifactPath = getLocalArtifactPath(artifactName); const binaryPath = args.binaryPath ?? getLocalBinaryPath(localArtifactPath); if (!binaryPath) { throw new RockError(`No binary found for "${artifactName}".`); } const buffer = await getBinaryBuffer(binaryPath, artifactName, localArtifactPath, args); const isArtifactIPA = args.binaryPath?.endsWith('.ipa'); const isArtifactAPK = args.binaryPath?.endsWith('.apk'); try { let uploadedArtifact; const appFileName = path.basename(binaryPath); const appName = appFileName.replace(/\.[^/.]+$/, ''); const uploadContent = { messagePrefix: 'build', artifactName: undefined, }; if (args.adHoc && isArtifactIPA) { uploadContent.messagePrefix = 'IPA, index.html and manifest.plist'; uploadContent.artifactName = `ad-hoc/${artifactName}/${appName}.ipa`; } else if (args.adHoc && isArtifactAPK) { uploadContent.messagePrefix = 'APK, index.html'; uploadContent.artifactName = `ad-hoc/${artifactName}/${appName}.apk`; } const { name, url, getResponse } = await remoteBuildCache.upload({ artifactName, uploadArtifactName: uploadContent.artifactName, }); const uploadMessage = `${uploadContent.messagePrefix} to ${color.bold(remoteBuildCache.name)}`; const loader = spinner({ silent: isJsonOutput }); loader.start(`Uploading ${uploadMessage}`); await handleUploadResponse(getResponse, buffer, (progress, totalMB) => { loader.message(`Uploading ${uploadMessage} (${progress}% of ${totalMB} MB)`); }); uploadedArtifact = { name, url }; // Upload index.html and manifest.plist for iOS ad-hoc distribution if (args.adHoc && isArtifactIPA) { const { version, bundleIdentifier } = await getInfoPlistFromIpa(binaryPath); const { url: urlIndexHtml, getResponse: getResponseIndexHtml } = await remoteBuildCache.upload({ artifactName, uploadArtifactName: `ad-hoc/${artifactName}/index.html`, }); getResponseIndexHtml(Buffer.from(templateIndexHtmlIOS({ appName, bundleIdentifier, version })), 'text/html'); const { getResponse: getResponseManifestPlist } = await remoteBuildCache.upload({ artifactName, uploadArtifactName: `ad-hoc/${artifactName}/manifest.plist`, }); getResponseManifestPlist((baseUrl) => Buffer.from(templateManifestPlist({ appName, version, baseUrl: baseUrl.replace('/manifest.plist', ''), ipaName: appFileName, bundleIdentifier, platformIdentifier: 'com.apple.platform.iphoneos', }))); // For ad-hoc distribution, we want the url to point to the index.html for easier installation uploadedArtifact = { name, url: urlIndexHtml.split('?')[0] + '' }; } // Upload index.html for Android ad-hoc distribution if (args.adHoc && isArtifactAPK) { const { version, packageName } = await getManifestFromApk(binaryPath); const { url: urlIndexHtml, getResponse: getResponseIndexHtml } = await remoteBuildCache.upload({ artifactName, uploadArtifactName: `ad-hoc/${artifactName}/index.html`, }); getResponseIndexHtml(Buffer.from(templateIndexHtmlAndroid({ appName, packageName, version })), 'text/html'); // For ad-hoc distribution, we want the url to point to the index.html for easier installation uploadedArtifact = { name, url: urlIndexHtml.split('?')[0] + '' }; } loader.stop(`Uploaded ${uploadMessage}`); if (isJsonOutput) { console.log(JSON.stringify(uploadedArtifact, null, 2)); } else { logger.log(`Artifact information: - name: ${color.bold(color.blue(uploadedArtifact.name))} - url: ${colorLink(uploadedArtifact.url)}`); } } catch (error) { throw new RockError(`Failed to upload build to ${color.bold(remoteBuildCache.name)}`, { cause: error }); } break; } case 'delete': { const deletedArtifacts = await remoteBuildCache.delete({ artifactName, limit: args.all || args.allButLatest ? undefined : 1, skipLatest: args.allButLatest, }); if (isJsonOutput) { console.log(JSON.stringify(deletedArtifacts, null, 2)); } else { logger.log(`Deleted artifacts: ${deletedArtifacts .map((artifact) => `- name: ${color.bold(color.blue(artifact.name))}\n- url: ${colorLink(artifact.url)}`) .join('\n')}`); } break; } case 'get-provider-name': { console.log(remoteBuildCache.name); break; } } return null; } async function getInfoPlistFromIpa(binaryPath) { const ipaFileName = path.basename(binaryPath); const appName = path.basename(ipaFileName, '.ipa'); const ipaPath = binaryPath; const zip = new AdmZip(ipaPath); const infoPlistPath = `Payload/${appName}.app/Info.plist`; const infoPlistEntry = zip.getEntry(infoPlistPath); if (!infoPlistEntry) { throw new RockError(`Info.plist not found at ${infoPlistPath} in ${ipaFileName}`); } const infoPlistBuffer = infoPlistEntry.getData(); const tempPlistPath = path.join(os.tmpdir(), 'rock-temp-info.plist'); fs.writeFileSync(tempPlistPath, infoPlistBuffer); const infoPlistJson = await getInfoPlist(tempPlistPath); fs.unlinkSync(tempPlistPath); return { version: infoPlistJson?.['CFBundleShortVersionString'] || infoPlistJson?.['CFBundleVersion'] || 'unknown', bundleIdentifier: infoPlistJson?.['CFBundleIdentifier'] || 'unknown', }; } function findAapt() { const sdkRoot = process.env['ANDROID_HOME'] || process.env['ANDROID_SDK_ROOT']; if (!sdkRoot) { throw new RockError('ANDROID_HOME or ANDROID_SDK_ROOT environment variable is not set. Please follow instructions at: https://reactnative.dev/docs/set-up-your-environment?platform=android'); } const buildToolsPath = path.join(sdkRoot, 'build-tools'); const versions = fs.readdirSync(buildToolsPath); for (const version of versions) { const aaptPath = path.join(buildToolsPath, version, 'aapt'); if (fs.existsSync(aaptPath)) { logger.debug(`Found aapt at: ${aaptPath}`); return aaptPath; } } throw new RockError(`"aapt" not found in Android Build-Tools directory: ${colorLink(buildToolsPath)} Please follow instructions at: https://reactnative.dev/docs/set-up-your-environment?platform=android`); } async function getManifestFromApk(binaryPath) { const apkFileName = path.basename(binaryPath, '.apk'); try { const aaptPath = findAapt(); const { stdout: output } = await spawn(aaptPath, ['dump', 'badging', binaryPath], { stdio: 'pipe' }); const packageMatch = output?.match(/package: name='([^']+)'/); const versionMatch = output?.match(/versionName='([^']+)'/); const packageName = packageMatch?.[1] || apkFileName; const version = versionMatch?.[1] || '1.0'; logger.debug(`Extracted APK manifest - package: ${packageName}, version: ${version}`); return { packageName, version }; } catch (error) { logger.debug('Failed to parse APK manifest, using fallback', error); return { packageName: apkFileName, version: '1.0', }; } } async function getBinaryBuffer(binaryPath, artifactName, localArtifactPath, args) { // For ad-hoc, we don't need to zip the binary, we just upload the IPA if (args.adHoc) { return fs.readFileSync(binaryPath); } const zip = new AdmZip(); const isAppDirectory = binaryPath.endsWith('.app') && fs.statSync(binaryPath).isDirectory(); const absoluteTarballPath = args.binaryPath ?? path.join(localArtifactPath, 'app.tar.gz'); if (isAppDirectory) { const appDirectoryName = path.basename(binaryPath); if (args.binaryPath && !fs.existsSync(absoluteTarballPath)) { throw new RockError(`No tarball found for "${artifactName}" in "${localArtifactPath}".`); } await tar.create({ file: absoluteTarballPath, cwd: path.dirname(binaryPath), gzip: true, filter: (filePath) => filePath.includes(appDirectoryName), }, [appDirectoryName]); zip.addLocalFile(absoluteTarballPath); } else { zip.addLocalFile(binaryPath); } const buffer = zip.toBuffer(); if (isAppDirectory) { fs.unlinkSync(absoluteTarballPath); } return buffer; } function validateArgs(args, action) { if (!action) { // @todo make Commander handle this throw new RockError('Action is required. Available actions: list, list-all, download, upload, delete'); } if (action === 'list-all' || action === 'get-provider-name') { // return early as we don't need to validate name or platform // to list all artifacts or get provider name return; } if (args.name && (args.platform || args.traits)) { throw new RockError('Cannot use "--name" together with "--platform" or "--traits". Use either name or platform with traits'); } if (!args.name) { if ((args.platform && !args.traits) || (!args.platform && args.traits)) { throw new RockError('Either "--platform" and "--traits" must be provided together'); } if (!args.platform || !args.traits) { throw new RockError('Either "--name" or "--platform" and "--traits" must be provided'); } } } export const remoteCachePlugin = () => (api) => { api.registerCommand({ name: 'remote-cache', description: 'Manage remote cache', action: async (action, args) => { await remoteCache({ action, args, remoteCacheProvider: (await api.getRemoteCacheProvider()) || null, projectRoot: api.getProjectRoot(), fingerprintOptions: api.getFingerprintOptions(), }); }, args: [ { name: '[action]', description: 'Select action, e.g. list, list-all, download, upload, delete, get-provider-name', }, ], options: [ { name: '--json', description: 'Output in JSON format', }, { name: '--name <string>', description: 'Full artifact name', }, { name: '--all', description: 'List or delete all matching artifacts. Affects "list" and "delete" actions only', }, { name: '--all-but-latest', description: 'Delete all but the latest matching artifact. Affects "delete" action only', }, { name: '-p, --platform <string>', description: 'Select platform, e.g. ios, android, or harmony (experimental)', }, { name: '-t, --traits <list>', description: `Comma-separated traits that construct final artifact name. Traits for Android are: variant; for iOS: destination and configuration. Example iOS: --traits simulator,Release Example Android: --traits debug Example Harmony: --traits debug`, parse: (val) => val.split(','), }, { name: '--binary-path <string>', description: 'Path to the binary to upload', }, { name: '--ad-hoc', description: 'Upload IPA or APK for ad-hoc distribution and installation from URL. For iOS: uploads IPA, index.html and manifest.plist. For Android: uploads APK and index.html', }, ], }); return { name: 'internal_remote-cache', description: 'Manage remote cache', }; }; //# sourceMappingURL=remoteCache.js.map