@capawesome/cli
Version:
The Capawesome Cloud Command Line Interface (CLI) to manage Live Updates and more.
300 lines (299 loc) • 14.7 kB
JavaScript
import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
import appBuildSourcesService from '../../../services/app-build-sources.js';
import appBuildsService from '../../../services/app-builds.js';
import appCertificatesService from '../../../services/app-certificates.js';
import appDeploymentsService from '../../../services/app-deployments.js';
import appEnvironmentsService from '../../../services/app-environments.js';
import { parseKeyValuePairs } from '../../../utils/app-environments.js';
import { withAuth } from '../../../utils/auth.js';
import { parseCustomProperties } from '../../../utils/custom-properties.js';
import { isInteractive } from '../../../utils/environment.js';
import { isReadable } from '../../../utils/file.js';
import { waitForJobCompletion } from '../../../utils/job.js';
import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
import zip from '../../../utils/zip.js';
import { defineCommand, defineOptions } from '@robingenz/zli';
import consola from 'consola';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
export default defineCommand({
description: 'Create a new live update by building and deploying web assets using Capawesome Cloud Runners.',
options: defineOptions(z.object({
androidEq: z.string().optional().describe('The exact Android versionCode for the live update.'),
androidMax: z.string().optional().describe('The maximum Android versionCode for the live update.'),
androidMin: z.string().optional().describe('The minimum Android versionCode for the live update.'),
appId: z
.uuid({
message: 'App ID must be a UUID.',
})
.optional()
.describe('App ID to create the live update for.'),
certificate: z.string().optional().describe('The name of the certificate to use for the build.'),
channel: z
.array(z.string())
.optional()
.describe('The name of the channel to deploy to. Can be specified multiple times.'),
customProperty: z
.array(z.string().min(1).max(100))
.max(10)
.optional()
.describe('A custom property to assign to the build. Must be in the format `key=value`. Can be specified multiple times.'),
environment: z.string().optional().describe('The name of the environment to use for the build.'),
gitRef: z.string().optional().describe('The Git reference (branch, tag, or commit SHA) to build.'),
iosEq: z.string().optional().describe('The exact iOS CFBundleVersion for the live update.'),
iosMax: z.string().optional().describe('The maximum iOS CFBundleVersion for the live update.'),
iosMin: z.string().optional().describe('The minimum iOS CFBundleVersion for the live update.'),
json: z.boolean().optional().describe('Output in JSON format.'),
path: z.string().optional().describe('Path to local source files to upload.'),
rolloutPercentage: z.coerce
.number()
.int()
.min(0)
.max(100)
.optional()
.describe('The rollout percentage for the deployment (0-100). Default: 100.'),
stack: z
.enum(['macos-sequoia', 'macos-tahoe'], {
message: 'Build stack must be either `macos-sequoia` or `macos-tahoe`.',
})
.optional()
.describe('The build stack to use for the build process.'),
url: z.string().optional().describe('URL to a zip file to use as build source.'),
variable: z
.array(z.string())
.optional()
.describe('Ad hoc environment variable in key=value format. Can be specified multiple times.'),
variableFile: z
.string()
.optional()
.describe('Path to a file containing ad hoc environment variables in .env format.'),
yes: z.boolean().optional().describe('Skip confirmation prompts.'),
}), { y: 'yes' }),
action: withAuth(async (options) => {
let { appId, certificate, channel, gitRef, environment, json, stack, path: sourcePath, url } = options;
// Validate that path, url, and gitRef cannot be used together
if (sourcePath && gitRef) {
consola.error('The --path and --git-ref flags cannot be used together.');
process.exit(1);
}
if (url && gitRef) {
consola.error('The --url and --git-ref flags cannot be used together.');
process.exit(1);
}
if (url && sourcePath) {
consola.error('The --url and --path flags cannot be used together.');
process.exit(1);
}
// Validate url if provided
if (url) {
consola.warn('The --url option is experimental and may change in the future.');
}
// Validate path if provided
if (sourcePath) {
consola.warn('The --path option is experimental and may change in the future.');
const resolvedPath = path.resolve(sourcePath);
const stat = await fs.stat(resolvedPath).catch(() => null);
if (!stat || !stat.isDirectory()) {
consola.error('The --path must point to an existing directory.');
process.exit(1);
}
const packageJsonPath = path.join(resolvedPath, 'package.json');
const packageJsonStat = await fs.stat(packageJsonPath).catch(() => null);
if (!packageJsonStat || !packageJsonStat.isFile()) {
consola.error('The directory specified by --path must contain a package.json file.');
process.exit(1);
}
}
// Prompt for app ID if not provided
if (!appId) {
if (!isInteractive()) {
consola.error('You must provide an app ID when running in non-interactive environment.');
process.exit(1);
}
const organizationId = await promptOrganizationSelection({ allowCreate: true });
appId = await promptAppSelection(organizationId, { allowCreate: true });
}
// Prompt for git ref if not provided and no path or url specified
if (!sourcePath && !url && !gitRef) {
if (!isInteractive()) {
consola.error('You must provide a git ref, path, or url when running in non-interactive environment.');
process.exit(1);
}
gitRef = await prompt('Enter the Git reference (branch, tag, or commit SHA):', {
type: 'text',
});
if (!gitRef) {
consola.error('You must provide a git ref.');
process.exit(1);
}
}
// Prompt for channel if not provided
if (!channel || channel.length === 0) {
if (!isInteractive()) {
consola.error('You must provide at least one channel when running in non-interactive environment.');
process.exit(1);
}
const channelName = await prompt('Enter the channel name to deploy to:', {
type: 'text',
});
if (!channelName) {
consola.error('You must provide a channel.');
process.exit(1);
}
channel = [channelName];
}
// Prompt for environment if not provided
if (!environment && !options.yes && isInteractive()) {
// @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
const selectEnvironment = await prompt('Do you want to select an environment?', {
type: 'confirm',
initial: false,
});
if (selectEnvironment) {
const environments = await appEnvironmentsService.findAll({ appId });
if (environments.length === 0) {
consola.warn('No environments found for this app.');
}
else {
// @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
environment = await prompt('Select the environment for the build:', {
type: 'select',
options: environments.map((env) => ({ label: env.name, value: env.name })),
});
}
}
}
// Prompt for certificate if not provided
if (!certificate && !options.yes && isInteractive()) {
// @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
const selectCertificate = await prompt('Do you want to select a certificate?', {
type: 'confirm',
initial: false,
});
if (selectCertificate) {
const certificates = await appCertificatesService.findAll({ appId, platform: 'web' });
if (certificates.length === 0) {
consola.warn('No certificates found for this app.');
}
else {
// @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
certificate = await prompt('Select the certificate for the build:', {
type: 'select',
options: certificates.map((cert) => ({ label: cert.name, value: cert.name })),
});
}
}
}
// Parse ad hoc environment variables from inline and file
const variablesMap = new Map();
if (options.variableFile) {
const variableFileReadable = await isReadable(options.variableFile);
if (!variableFileReadable) {
consola.error(`The variable file does not exist or is not accessible: ${options.variableFile}`);
process.exit(1);
}
const fileContent = await fs.readFile(options.variableFile, 'utf-8');
const fileVariables = parseKeyValuePairs(fileContent);
fileVariables.forEach((v) => variablesMap.set(v.key, v.value));
}
if (options.variable) {
const inlineVariables = parseKeyValuePairs(options.variable.join('\n'));
inlineVariables.forEach((v) => variablesMap.set(v.key, v.value));
}
const adHocEnvironmentVariables = variablesMap.size > 0 ? Object.fromEntries(variablesMap) : undefined;
// Create build source from URL if provided
let appBuildSourceId;
if (url) {
consola.start('Creating build source from URL...');
const appBuildSource = await appBuildSourcesService.createFromUrl({ appId, fileUrl: url });
appBuildSourceId = appBuildSource.id;
consola.success('Build source created successfully.');
}
// Upload source files if path is provided
if (sourcePath) {
const resolvedPath = path.resolve(sourcePath);
consola.start('Zipping source files...');
const buffer = await zip.zipFolderWithGitignore(resolvedPath);
consola.start('Uploading source files...');
const appBuildSource = await appBuildSourcesService.createFromFile({
appId,
fileSizeInBytes: buffer.byteLength,
buffer,
name: 'source.zip',
}, (currentPart, totalParts) => {
consola.start(`Uploading source files (${currentPart}/${totalParts})...`);
});
appBuildSourceId = appBuildSource.id;
consola.success('Source files uploaded successfully.');
}
// Create the web build
consola.start('Creating build...');
const response = await appBuildsService.create({
adHocEnvironmentVariables,
appBuildSourceId,
appCertificateName: certificate,
appEnvironmentName: environment,
appId,
stack,
gitRef,
platform: 'web',
});
consola.info(`Build ID: ${response.id}`);
consola.info(`Build Number: ${response.numberAsString}`);
consola.info(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${response.id}`);
consola.success('Build created successfully.');
// Wait for build to complete
await waitForJobCompletion({ jobId: response.jobId });
consola.success('Build completed successfully.');
console.log();
// Update build with custom properties and version constraints if any are provided
const customProperties = parseCustomProperties(options.customProperty);
const hasUpdateFields = customProperties ||
options.androidMin ||
options.androidMax ||
options.androidEq ||
options.iosMin ||
options.iosMax ||
options.iosEq;
if (hasUpdateFields) {
consola.start('Updating build...');
await appBuildsService.update({
appId,
appBuildId: response.id,
customProperties,
minAndroidAppVersionCode: options.androidMin,
maxAndroidAppVersionCode: options.androidMax,
eqAndroidAppVersionCode: options.androidEq,
minIosAppVersionCode: options.iosMin,
maxIosAppVersionCode: options.iosMax,
eqIosAppVersionCode: options.iosEq,
});
consola.success('Build updated successfully.');
}
// Deploy to channels
const rolloutPercentage = (options.rolloutPercentage ?? 100) / 100;
const deploymentIds = [];
for (const channelName of channel) {
consola.start(`Creating deployment for channel "${channelName}"...`);
const deployment = await appDeploymentsService.create({
appId,
appBuildId: response.id,
appChannelName: channelName,
rolloutPercentage,
});
deploymentIds.push(deployment.id);
consola.info(`Deployment ID: ${deployment.id}`);
consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${deployment.id}`);
consola.success('Deployment created successfully.');
}
// Output JSON if json flag is set
if (json) {
console.log(JSON.stringify({
buildId: response.id,
buildNumberAsString: response.numberAsString,
deploymentIds,
}, null, 2));
}
}),
});