UNPKG

@capawesome/cli

Version:

The Capawesome Cloud Command Line Interface (CLI) to manage Live Updates and more.

535 lines (534 loc) 23.5 kB
import { MAX_CONCURRENT_UPLOADS } from '../../../config/index.js'; import appBundleFilesService from '../../../services/app-bundle-files.js'; import appBundlesService from '../../../services/app-bundles.js'; import appsService from '../../../services/apps.js'; import authorizationService from '../../../services/authorization-service.js'; import organizationsService from '../../../services/organizations.js'; import { createBufferFromPath, createBufferFromReadStream, createBufferFromString, isPrivateKeyContent, } from '../../../utils/buffer.js'; import { findCapacitorConfigPath, getLiveUpdatePluginAppIdFromConfig, getLiveUpdatePluginPublicKeyFromConfig, getWebDirFromConfig, } from '../../../utils/capacitor-config.js'; import { fileExistsAtPath, getFilesInDirectoryAndSubdirectories, isDirectory } from '../../../utils/file.js'; import { createHash } from '../../../utils/hash.js'; import { generateManifestJson } from '../../../utils/manifest.js'; import { findPackageJsonPath, getBuildScript } from '../../../utils/package-json.js'; import { formatPrivateKey } from '../../../utils/private-key.js'; import { prompt } from '../../../utils/prompt.js'; import { createSignature } from '../../../utils/signature.js'; import zip from '../../../utils/zip.js'; import { defineCommand, defineOptions } from '@robingenz/zli'; import { exec } from 'child_process'; import consola from 'consola'; import { createReadStream } from 'fs'; import pathModule from 'path'; import { promisify } from 'util'; import { z } from 'zod'; import { isInteractive } from '../../../utils/environment.js'; // Promisified exec for running build scripts const execAsync = promisify(exec); export default defineCommand({ description: 'Create a new app bundle.', options: defineOptions(z.object({ androidMax: z.coerce .string() .optional() .describe('The maximum Android version code (`versionCode`) that the bundle supports.'), androidMin: z.coerce .string() .optional() .describe('The minimum Android version code (`versionCode`) that the bundle supports.'), androidEq: z.coerce .string() .optional() .describe('The exact Android version code (`versionCode`) that the bundle does not support.'), appId: z .string({ message: 'App ID must be a UUID.', }) .uuid({ message: 'App ID must be a UUID.', }) .optional() .describe('App ID to deploy to.'), artifactType: z .enum(['manifest', 'zip'], { message: 'Invalid artifact type. Must be either `manifest` or `zip`.', }) .optional() .describe('The type of artifact to deploy. Must be either `manifest` or `zip`. The default is `zip`.') .default('zip'), channel: z.string().optional().describe('Channel to associate the bundle with.'), commitMessage: z.string().optional().describe('The commit message related to the bundle.'), commitRef: z.string().optional().describe('The commit ref related to the bundle.'), commitSha: z.string().optional().describe('The commit sha related to the bundle.'), customProperty: z .array(z.string().min(1).max(100)) .optional() .describe('A custom property to assign to the bundle. Must be in the format `key=value`. Can be specified multiple times.'), expiresInDays: z.coerce .number({ message: 'Expiration days must be an integer.', }) .int({ message: 'Expiration days must be an integer.', }) .optional() .describe('The number of days until the bundle is automatically deleted.'), iosMax: z .string() .optional() .describe('The maximum iOS bundle version (`CFBundleVersion`) that the bundle supports.'), iosMin: z .string() .optional() .describe('The minimum iOS bundle version (`CFBundleVersion`) that the bundle supports.'), iosEq: z .string() .optional() .describe('The exact iOS bundle version (`CFBundleVersion`) that the bundle does not support.'), path: z .string() .optional() .describe('Path to the bundle to upload. Must be a folder (e.g. `www` or `dist`) or a zip file.'), privateKey: z .string() .optional() .describe('The private key to sign the bundle with. Can be a file path to a .pem file or the private key content as plain text.'), rollout: z.coerce .number() .min(0) .max(1, { message: 'Rollout percentage must be a number between 0 and 1 (e.g. 0.5).', }) .optional() .default(1) .describe('The percentage of devices to deploy the bundle to. Must be a number between 0 and 1 (e.g. 0.5).'), url: z.string().optional().describe('The url to the self-hosted bundle file.'), })), action: async (options, args) => { let { androidEq, androidMax, androidMin, appId, artifactType, channel, commitMessage, commitRef, commitSha, customProperty, expiresInDays, iosEq, iosMax, iosMin, path, privateKey, rollout, url, } = options; // Check if the user is logged in if (!authorizationService.hasAuthorizationToken()) { consola.error('You must be logged in to run this command. Please run the `login` command first.'); process.exit(1); } // Calculate the expiration date let expiresAt; if (expiresInDays) { const expiresAtDate = new Date(); expiresAtDate.setDate(expiresAtDate.getDate() + expiresInDays); expiresAt = expiresAtDate.toISOString(); } // Try to auto-detect webDir from Capacitor configuration const capacitorConfigPath = await findCapacitorConfigPath(); if (!capacitorConfigPath) { consola.warn('No Capacitor configuration found to auto-detect web asset directory or app ID.'); } // Check that either a path or a url is provided if (!path && !url) { // Try to auto-detect webDir from Capacitor configuration if (capacitorConfigPath) { const webDirPath = await getWebDirFromConfig(capacitorConfigPath); if (webDirPath) { const relativeWebDirPath = pathModule.relative(process.cwd(), webDirPath); consola.success(`Auto-detected web asset directory "${relativeWebDirPath}" from Capacitor configuration.`); path = webDirPath; } else { consola.warn('No web asset directory found in Capacitor configuration (`webDir`).'); } } // If still no path, prompt the user if (!path) { if (!isInteractive()) { consola.error('You must provide either a path or a url when running in non-interactive environment.'); process.exit(1); } else { path = await prompt('Enter the path to the app bundle:', { type: 'text', }); if (!path) { consola.error('You must provide a path to the app bundle.'); process.exit(1); } } } } // Check for build scripts if a path is provided or detected if (path && !url) { const packageJsonPath = await findPackageJsonPath(); if (!packageJsonPath) { consola.warn('No package.json file found.'); } else { const buildScript = await getBuildScript(packageJsonPath); if (!buildScript) { consola.warn('No build script (`capawesome:build` or `build`) found in package.json.'); } else if (isInteractive()) { const shouldBuild = await prompt('Do you want to run the build script before creating the bundle to ensure the latest assets are included?', { type: 'confirm', initial: true, }); if (shouldBuild) { try { consola.start(`Running \`${buildScript.name}\` script...`); const { stdout, stderr } = await execAsync(`npm run ${buildScript.name}`); if (stdout) { console.log(stdout); } if (stderr) { console.error(stderr); } consola.success('Build completed successfully.'); } catch (error) { consola.error('Build failed.'); if (error.stdout) { console.log(error.stdout); } if (error.stderr) { console.error(error.stderr); } process.exit(1); } } } } } // Validate the provided path if (path) { // Check if the path exists when a path is provided const pathExists = await fileExistsAtPath(path); if (!pathExists) { consola.error(`The path does not exist.`); process.exit(1); } // Check if the directory contains an index.html file const pathIsDirectory = await isDirectory(path); if (pathIsDirectory) { const files = await getFilesInDirectoryAndSubdirectories(path); const indexHtml = files.find((file) => file.href === 'index.html'); if (!indexHtml) { consola.error('The directory must contain an `index.html` file.'); process.exit(1); } } else if (zip.isZipped(path)) { // No-op } else { consola.error('The path must be either a folder or a zip file.'); process.exit(1); } } // Check that the path is a directory when creating a bundle with an artifact type if (artifactType === 'manifest' && path) { const pathIsDirectory = await isDirectory(path); if (!pathIsDirectory) { consola.error('The path must be a folder when creating a bundle with an artifact type of `manifest`.'); process.exit(1); } } // Check that a URL is not provided when creating a bundle with an artifact type of manifest if (artifactType === 'manifest' && url) { consola.error('It is not yet possible to provide a URL when creating a bundle with an artifact type of `manifest`.'); process.exit(1); } // Track if we found a Capacitor configuration but no app ID (for showing setup hint later) if (!appId) { // Try to auto-detect appId from Capacitor configuration if (capacitorConfigPath) { const configAppId = await getLiveUpdatePluginAppIdFromConfig(capacitorConfigPath); if (configAppId) { consola.success(`Auto-detected Capawesome Cloud app ID "${configAppId}" from Capacitor configuration.`); appId = configAppId; } else { consola.warn('No Capawesome Cloud app ID found in Capacitor configuration (`plugins.LiveUpdate.appId`).'); } } // If still no appId, prompt the user if (!appId) { if (!isInteractive()) { consola.error('You must provide an app ID when running in non-interactive environment.'); process.exit(1); } const organizations = await organizationsService.findAll(); if (organizations.length === 0) { consola.error('You must create an organization before creating a bundle.'); process.exit(1); } // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged const organizationId = await prompt('Select the organization of the app for which you want to create a bundle.', { type: 'select', options: organizations.map((organization) => ({ label: organization.name, value: organization.id })), }); if (!organizationId) { consola.error('You must select the organization of an app for which you want to create a bundle.'); process.exit(1); } const apps = await appsService.findAll({ organizationId, }); if (apps.length === 0) { consola.error('You must create an app before creating a bundle.'); process.exit(1); } // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged appId = await prompt('Which app do you want to deploy to:', { type: 'select', options: apps.map((app) => ({ label: app.name, value: app.id })), }); if (!appId) { consola.error('You must select an app to deploy to.'); process.exit(1); } } } if (!channel && isInteractive()) { const shouldDeployToChannel = await prompt('Do you want to deploy to a specific channel?', { type: 'confirm', initial: false, }); if (shouldDeployToChannel) { channel = await prompt('Enter the channel name:', { type: 'text', }); if (!channel) { consola.error('The channel name must be at least one character long.'); process.exit(1); } } } // Check if public key is configured but no private key was provided if (!privateKey && capacitorConfigPath) { const publicKey = await getLiveUpdatePluginPublicKeyFromConfig(capacitorConfigPath); if (publicKey) { consola.warn('A public key for verifying the integrity of the bundles is configured in your Capacitor configuration, but no private key has been provided for signing this bundle.'); } } // Create the private key buffer let privateKeyBuffer; if (privateKey) { if (isPrivateKeyContent(privateKey)) { // Handle plain text private key content const formattedPrivateKey = formatPrivateKey(privateKey); privateKeyBuffer = createBufferFromString(formattedPrivateKey); } else if (privateKey.endsWith('.pem')) { // Handle file path const fileExists = await fileExistsAtPath(privateKey); if (fileExists) { const keyBuffer = await createBufferFromPath(privateKey); const keyContent = keyBuffer.toString('utf8'); const formattedPrivateKey = formatPrivateKey(keyContent); privateKeyBuffer = createBufferFromString(formattedPrivateKey); } else { consola.error('Private key file not found.'); process.exit(1); } } else { consola.error('Private key must be either a path to a .pem file or the private key content as plain text.'); process.exit(1); } } // Get app details for confirmation const app = await appsService.findOne({ appId }); const appName = app.name; // Final confirmation before creating bundle if (path && isInteractive()) { const relativePath = pathModule.relative(process.cwd(), path); const confirmed = await prompt(`Are you sure you want to create a bundle from path "${relativePath}" for app "${appName}" (${appId})?`, { type: 'confirm', }); if (!confirmed) { consola.info('Bundle creation cancelled.'); process.exit(0); } } // Create the app bundle let appBundleId; try { consola.start('Creating bundle...'); let checksum; let signature; if (path && url) { // Create the file buffer if (!zip.isZipped(path)) { consola.error('The path must be a zip file when providing a URL.'); process.exit(1); } const fileBuffer = await createBufferFromPath(path); // Generate checksum checksum = await createHash(fileBuffer); // Sign the bundle if (privateKeyBuffer) { signature = await createSignature(privateKeyBuffer, fileBuffer); } } const response = await appBundlesService.create({ appId, artifactType, channelName: channel, checksum, eqAndroidAppVersionCode: androidEq, eqIosAppVersionCode: iosEq, gitCommitMessage: commitMessage, gitCommitRef: commitRef, gitCommitSha: commitSha, customProperties: parseCustomProperties(customProperty), expiresAt, url, maxAndroidAppVersionCode: androidMax, maxIosAppVersionCode: iosMax, minAndroidAppVersionCode: androidMin, minIosAppVersionCode: iosMin, rolloutPercentage: rollout, signature, }); appBundleId = response.id; if (path) { if (url) { // Important: Do NOT upload files if the URL is provided. // The user wants to self-host the bundle. The path is only needed for code signing. } else { let appBundleFileId; // Upload the app bundle files if (artifactType === 'manifest') { await uploadFiles({ appId, appBundleId: response.id, path, privateKeyBuffer }); } else { const result = await uploadZip({ appId, appBundleId: response.id, path, privateKeyBuffer }); appBundleFileId = result.appBundleFileId; } // Update the app bundle consola.start('Updating bundle...'); await appBundlesService.update({ appBundleFileId, appId, artifactStatus: 'ready', appBundleId: response.id, }); } } consola.success('Bundle successfully created.'); consola.info(`Bundle ID: ${response.id}`); } catch (error) { if (appBundleId) { await appBundlesService.delete({ appId, appBundleId }).catch(() => { // No-op }); } throw error; } }, }); const uploadFile = async (options) => { let { appId, appBundleId, buffer, href, mimeType, name, privateKeyBuffer, retryOnFailure } = options; try { // Generate checksum const hash = await createHash(buffer); // Sign the bundle let signature; if (privateKeyBuffer) { signature = await createSignature(privateKeyBuffer, buffer); } // Create the multipart upload return await appBundleFilesService.create({ appId, appBundleId, buffer, checksum: hash, href, mimeType, name, signature, }); } catch (error) { if (retryOnFailure) { return uploadFile({ ...options, retryOnFailure: false, }); } throw error; } }; const uploadFiles = async (options) => { let { appId, appBundleId, path, privateKeyBuffer } = options; // Generate the manifest file await generateManifestJson(path); // Get all files in the directory const files = await getFilesInDirectoryAndSubdirectories(path); // Iterate over each file let fileIndex = 0; const uploadNextFile = async () => { if (fileIndex >= files.length) { return; } const file = files[fileIndex]; fileIndex++; consola.start(`Uploading file (${fileIndex}/${files.length})...`); const buffer = await createBufferFromPath(file.path); await uploadFile({ appId, appBundleId: appBundleId, buffer, href: file.href, mimeType: file.mimeType, name: file.name, privateKeyBuffer: privateKeyBuffer, retryOnFailure: true, }); await uploadNextFile(); }; const uploadPromises = Array.from({ length: MAX_CONCURRENT_UPLOADS }); for (let i = 0; i < MAX_CONCURRENT_UPLOADS; i++) { uploadPromises[i] = uploadNextFile(); } await Promise.all(uploadPromises); }; const uploadZip = async (options) => { let { appId, appBundleId, path, privateKeyBuffer } = options; // Read the zip file let fileBuffer; if (zip.isZipped(path)) { const readStream = createReadStream(path); fileBuffer = await createBufferFromReadStream(readStream); } else { consola.start('Zipping folder...'); fileBuffer = await zip.zipFolder(path); } // Upload the zip file consola.start('Uploading file...'); const result = await uploadFile({ appId, appBundleId: appBundleId, buffer: fileBuffer, mimeType: 'application/zip', name: 'bundle.zip', privateKeyBuffer: privateKeyBuffer, }); return { appBundleFileId: result.id, }; }; const parseCustomProperties = (customProperty) => { let customProperties; if (customProperty) { customProperties = {}; for (const property of customProperty) { const [key, value] = property.split('='); if (key && value) { customProperties[key] = value; } } } return customProperties; };