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