@capawesome/cli
Version:
The Capawesome Cloud Command Line Interface (CLI) to manage Live Updates and more.
535 lines (534 loc) • 23.5 kB
JavaScript
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;
};