UNPKG

@capawesome/cli

Version:

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

415 lines (414 loc) 18 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const citty_1 = require("citty"); const consola_1 = __importDefault(require("consola")); const fs_1 = require("fs"); const app_bundle_files_1 = __importDefault(require("../../../services/app-bundle-files")); const app_bundles_1 = __importDefault(require("../../../services/app-bundles")); const apps_1 = __importDefault(require("../../../services/apps")); const authorization_service_1 = __importDefault(require("../../../services/authorization-service")); const buffer_1 = require("../../../utils/buffer"); const error_1 = require("../../../utils/error"); const file_1 = require("../../../utils/file"); const hash_1 = require("../../../utils/hash"); const manifest_1 = require("../../../utils/manifest"); const prompt_1 = require("../../../utils/prompt"); const signature_1 = require("../../../utils/signature"); const zip_1 = __importDefault(require("../../../utils/zip")); exports.default = (0, citty_1.defineCommand)({ meta: { description: 'Create a new app bundle.', }, args: { androidMax: { type: 'string', description: 'The maximum Android version code (`versionCode`) that the bundle supports.', }, androidMin: { type: 'string', description: 'The minimum Android version code (`versionCode`) that the bundle supports.', }, appId: { type: 'string', description: 'App ID to deploy to.', }, artifactType: { type: 'string', description: 'The type of artifact to deploy. Must be either `manifest` or `zip`. The default is `zip`.', }, channel: { type: 'string', description: 'Channel to associate the bundle with.', }, commitMessage: { type: 'string', description: 'The commit message related to the bundle.', }, commitRef: { type: 'string', description: 'The commit ref related to the bundle.', }, commitSha: { type: 'string', description: 'The commit sha related to the bundle.', }, customProperty: { type: 'string', description: 'A custom property to assign to the bundle. Must be in the format `key=value`. Can be specified multiple times.', }, expiresInDays: { type: 'string', description: 'The number of days until the bundle is automatically deleted.', }, iosMax: { type: 'string', description: 'The maximum iOS bundle version (`CFBundleVersion`) that the bundle supports.', }, iosMin: { type: 'string', description: 'The minimum iOS bundle version (`CFBundleVersion`) that the bundle supports.', }, path: { type: 'string', description: 'Path to the bundle to upload. Must be a folder (e.g. `www` or `dist`) or a zip file.', }, privateKey: { type: 'string', description: 'The path to the private key file to sign the bundle with.', }, rollout: { type: 'string', description: 'The percentage of devices to deploy the bundle to. Must be a number between 0 and 1 (e.g. 0.5).', }, url: { type: 'string', description: 'The url to the self-hosted bundle file.', }, }, run: (ctx) => __awaiter(void 0, void 0, void 0, function* () { if (!authorization_service_1.default.hasAuthorizationToken()) { consola_1.default.error('You must be logged in to run this command.'); process.exit(1); } let androidMax = ctx.args.androidMax === undefined ? undefined : ctx.args.androidMax + ''; // Convert to string let androidMin = ctx.args.androidMin === undefined ? undefined : ctx.args.androidMin + ''; // Convert to string let appId = ctx.args.appId; let artifactType = ctx.args.artifactType === 'manifest' || ctx.args.artifactType === 'zip' ? ctx.args.artifactType : 'zip'; let channelName = ctx.args.channel; let customProperty = ctx.args.customProperty; let expiresInDays = ctx.args.expiresInDays === undefined ? undefined : ctx.args.expiresInDays + ''; // Convert to string let iosMax = ctx.args.iosMax === undefined ? undefined : ctx.args.iosMax + ''; // Convert to string let iosMin = ctx.args.iosMin === undefined ? undefined : ctx.args.iosMin + ''; // Convert to string let path = ctx.args.path; let privateKey = ctx.args.privateKey; let rolloutAsString = ctx.args.rollout === undefined ? undefined : ctx.args.rollout + ''; // Convert to string let url = ctx.args.url; let commitMessage = ctx.args.commitMessage; let commitRef = ctx.args.commitRef; let commitSha = ctx.args.commitSha; // Validate the expiration days let expiresAt; if (expiresInDays) { const expiresInDaysAsNumber = parseInt(expiresInDays, 10); if (isNaN(expiresInDaysAsNumber) || expiresInDaysAsNumber < 1) { consola_1.default.error('Expires in days must be a number greater than 0.'); process.exit(1); } const expiresAtDate = new Date(); expiresAtDate.setDate(expiresAtDate.getDate() + expiresInDaysAsNumber); expiresAt = expiresAtDate.toISOString(); } // Validate the rollout percentage let rolloutPercentage = 1; if (rolloutAsString) { const rolloutAsNumber = parseFloat(rolloutAsString); if (isNaN(rolloutAsNumber) || rolloutAsNumber < 0 || rolloutAsNumber > 1) { consola_1.default.error('Rollout percentage must be a number between 0 and 1.'); process.exit(1); } rolloutPercentage = rolloutAsNumber; } // Check that either a path or a url is provided if (!path && !url) { path = yield (0, prompt_1.prompt)('Enter the path to the app bundle:', { type: 'text', }); if (!path) { consola_1.default.error('You must provide a path to the app bundle.'); process.exit(1); } } if (path) { // Check if the path exists when a path is provided const pathExists = yield (0, file_1.fileExistsAtPath)(path); if (!pathExists) { consola_1.default.error(`The path does not exist.`); process.exit(1); } // Check if the directory contains an index.html file const pathIsDirectory = yield (0, file_1.isDirectory)(path); if (pathIsDirectory) { const files = yield (0, file_1.getFilesInDirectoryAndSubdirectories)(path); const indexHtml = files.find((file) => file.href === 'index.html'); if (!indexHtml) { consola_1.default.error('The directory must contain an `index.html` 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 = yield (0, file_1.isDirectory)(path); if (!pathIsDirectory) { consola_1.default.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_1.default.error('It is not yet possible to provide a URL when creating a bundle with an artifact type of `manifest`.'); process.exit(1); } // Let the user select an app and channel if not provided if (!appId) { const apps = yield apps_1.default.findAll(); if (apps.length === 0) { consola_1.default.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 = yield (0, prompt_1.prompt)('Which app do you want to deploy to:', { type: 'select', options: apps.map((app) => ({ label: app.name, value: app.id })), }); if (!appId) { consola_1.default.error('You must select an app to deploy to.'); process.exit(1); } if (!channelName) { const promptChannel = yield (0, prompt_1.prompt)('Do you want to deploy to a specific channel?', { type: 'select', options: ['Yes', 'No'], }); if (promptChannel === 'Yes') { channelName = yield (0, prompt_1.prompt)('Enter the channel name:', { type: 'text', }); if (!channelName) { consola_1.default.error('The channel name must be at least one character long.'); process.exit(1); } } } } // Create the private key buffer let privateKeyBuffer; if (privateKey) { if (privateKey.endsWith('.pem')) { const fileExists = yield (0, file_1.fileExistsAtPath)(privateKey); if (fileExists) { privateKeyBuffer = yield (0, buffer_1.createBufferFromPath)(privateKey); } else { consola_1.default.error('Private key file not found.'); process.exit(1); } } else { consola_1.default.error('Private key must be a path to a .pem file.'); process.exit(1); } } let appBundleId; try { // Create the app bundle consola_1.default.start('Creating bundle...'); let checksum; let signature; if (path && url) { // Create the file buffer if (!zip_1.default.isZipped(path)) { consola_1.default.error('The path must be a zip file when providing a URL.'); process.exit(1); } const fileBuffer = yield (0, buffer_1.createBufferFromPath)(path); // Generate checksum checksum = yield (0, hash_1.createHash)(fileBuffer); // Sign the bundle if (privateKeyBuffer) { signature = yield (0, signature_1.createSignature)(privateKeyBuffer, fileBuffer); } } const response = yield app_bundles_1.default.create({ appId, artifactType, channelName, checksum, gitCommitMessage: commitMessage, gitCommitRef: commitRef, gitCommitSha: commitSha, customProperties: parseCustomProperties(customProperty), expiresAt: expiresAt, url, maxAndroidAppVersionCode: androidMax, maxIosAppVersionCode: iosMax, minAndroidAppVersionCode: androidMin, minIosAppVersionCode: iosMin, rolloutPercentage, 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') { yield uploadFiles({ appId, appBundleId: response.id, path, privateKeyBuffer }); } else { const result = yield uploadZip({ appId, appBundleId: response.id, path, privateKeyBuffer }); appBundleFileId = result.appBundleFileId; } // Update the app bundle consola_1.default.start('Updating bundle...'); yield app_bundles_1.default.update({ appBundleFileId, appId, artifactStatus: 'ready', appBundleId: response.id }); } } consola_1.default.success('Bundle successfully created.'); consola_1.default.info(`Bundle ID: ${response.id}`); } catch (error) { if (appBundleId) { yield app_bundles_1.default.delete({ appId, appBundleId }).catch(() => { // No-op }); } const message = (0, error_1.getMessageFromUnknownError)(error); consola_1.default.error(message); process.exit(1); } }), }); const uploadFile = (options) => __awaiter(void 0, void 0, void 0, function* () { try { // Generate checksum const hash = yield (0, hash_1.createHash)(options.fileBuffer); // Sign the bundle let signature; if (options.privateKeyBuffer) { signature = yield (0, signature_1.createSignature)(options.privateKeyBuffer, options.fileBuffer); } // Create the multipart upload return yield app_bundle_files_1.default.create({ appId: options.appId, appBundleId: options.appBundleId, checksum: hash, fileBuffer: options.fileBuffer, fileName: options.fileName, href: options.href, signature, }); } catch (error) { if (options.retryOnFailure) { return uploadFile(Object.assign(Object.assign({}, options), { retryOnFailure: false })); } throw error; } }); const uploadFiles = (options) => __awaiter(void 0, void 0, void 0, function* () { // Generate the manifest file yield (0, manifest_1.generateManifestJson)(options.path); // Get all files in the directory const files = yield (0, file_1.getFilesInDirectoryAndSubdirectories)(options.path); // Iterate over each file const MAX_CONCURRENT_UPLOADS = 20; let fileIndex = 0; const uploadNextFile = () => __awaiter(void 0, void 0, void 0, function* () { if (fileIndex >= files.length) { return; } const file = files[fileIndex]; fileIndex++; consola_1.default.start(`Uploading file (${fileIndex}/${files.length})...`); const fileBuffer = yield (0, buffer_1.createBufferFromPath)(file.path); yield uploadFile({ appId: options.appId, appBundleId: options.appBundleId, fileBuffer, fileName: file.name, href: file.href, privateKeyBuffer: options.privateKeyBuffer, retryOnFailure: true, }); yield uploadNextFile(); }); const uploadPromises = Array(MAX_CONCURRENT_UPLOADS); for (let i = 0; i < MAX_CONCURRENT_UPLOADS; i++) { uploadPromises[i] = uploadNextFile(); } yield Promise.all(uploadPromises); }); const uploadZip = (options) => __awaiter(void 0, void 0, void 0, function* () { // Read the zip file let fileBuffer; if (zip_1.default.isZipped(options.path)) { const readStream = (0, fs_1.createReadStream)(options.path); fileBuffer = yield (0, buffer_1.createBufferFromReadStream)(readStream); } else { consola_1.default.start('Zipping folder...'); fileBuffer = yield zip_1.default.zipFolder(options.path); } // Upload the zip file consola_1.default.start('Uploading file...'); const result = yield uploadFile({ appId: options.appId, appBundleId: options.appBundleId, fileBuffer, fileName: 'bundle.zip', privateKeyBuffer: options.privateKeyBuffer, }); return { appBundleFileId: result.id, }; }); const parseCustomProperties = (customProperty) => { let customProperties; if (customProperty) { customProperties = {}; if (Array.isArray(customProperty)) { for (const property of customProperty) { const [key, value] = property.split('='); if (key && value) { customProperties[key] = value; } } } else { const [key, value] = customProperty.split('='); if (key && value) { customProperties[key] = value; } } } return customProperties; };