@capawesome/cli
Version:
The Capawesome Cloud Command Line Interface (CLI) to manage Live Updates and more.
405 lines (404 loc) • 18.8 kB
JavaScript
import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
import appBuildsService from '../../../services/app-builds.js';
import appCertificatesService from '../../../services/app-certificates.js';
import appEnvironmentsService from '../../../services/app-environments.js';
import appsService from '../../../services/apps.js';
import authorizationService from '../../../services/authorization-service.js';
import organizationsService from '../../../services/organizations.js';
import { unescapeAnsi } from '../../../utils/ansi.js';
import { prompt } from '../../../utils/prompt.js';
import { wait } from '../../../utils/wait.js';
import { defineCommand, defineOptions } from '@robingenz/zli';
import consola from 'consola';
import fs from 'fs/promises';
import path from 'path';
import { isInteractive } from '../../../utils/environment.js';
import { z } from 'zod';
const IOS_BUILD_TYPES = ['simulator', 'development', 'ad-hoc', 'app-store', 'enterprise'];
const ANDROID_BUILD_TYPES = ['debug', 'release'];
export default defineCommand({
description: 'Create a new app build.',
options: defineOptions(z.object({
aab: z
.union([z.boolean(), z.string()])
.optional()
.describe('Download the generated AAB file (Android only). Optionally provide a file path.'),
apk: z
.union([z.boolean(), z.string()])
.optional()
.describe('Download the generated APK file (Android only). Optionally provide a file path.'),
appId: z
.uuid({
message: 'App ID must be a UUID.',
})
.optional()
.describe('App ID to create the build for.'),
certificate: z.string().optional().describe('The name of the certificate to use for the build.'),
detached: z
.boolean()
.optional()
.describe('Exit immediately after creating the build without waiting for completion.'),
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.'),
ipa: z
.union([z.boolean(), z.string()])
.optional()
.describe('Download the generated IPA file (iOS only). Optionally provide a file path.'),
json: z.boolean().optional().describe('Output in JSON format.'),
platform: z
.enum(['ios', 'android'], {
message: 'Platform must be either `ios` or `android`.',
})
.optional()
.describe('The platform for the build. Supported values are `ios` and `android`.'),
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.'),
type: z
.string()
.optional()
.describe('The type of build. For iOS, supported values are `simulator`, `development`, `ad-hoc`, `app-store`, and `enterprise`. For Android, supported values are `debug` and `release`.'),
})),
action: async (options) => {
let { appId, platform, type, gitRef, environment, certificate, json, stack } = 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);
}
// Validate that detached flag cannot be used with artifact flags
if (options.detached && (options.apk || options.aab || options.ipa)) {
consola.error('The --detached flag cannot be used with --apk, --aab, or --ipa flags.');
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 organizations = await organizationsService.findAll();
if (organizations.length === 0) {
consola.error('You must create an organization before creating a build.');
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 build.', {
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 build.');
process.exit(1);
}
const apps = await appsService.findAll({
organizationId,
});
if (apps.length === 0) {
consola.error('You must create an app before creating a build.');
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 create a build for:', {
type: 'select',
options: apps.map((app) => ({ label: app.name, value: app.id })),
});
if (!appId) {
consola.error('You must select an app to create a build for.');
process.exit(1);
}
}
// Prompt for platform if not provided
if (!platform) {
if (!isInteractive()) {
consola.error('You must provide a platform when running in non-interactive environment.');
process.exit(1);
}
// @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
platform = await prompt('Select the platform for the build:', {
type: 'select',
options: [
{ label: 'Android', value: 'android' },
{ label: 'iOS', value: 'ios' },
],
});
if (!platform) {
consola.error('You must select a platform.');
process.exit(1);
}
}
// Prompt for git ref if not provided
if (!gitRef) {
if (!isInteractive()) {
consola.error('You must provide a git ref 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);
}
}
// Set default type based on platform if not provided
if (!type) {
type = platform === 'android' ? 'debug' : 'simulator';
}
// Validate type based on platform
if (platform === 'ios' && !IOS_BUILD_TYPES.includes(type)) {
consola.error(`Invalid build type for iOS. Supported values are: ${IOS_BUILD_TYPES.map((t) => `\`${t}\``).join(', ')}.`);
process.exit(1);
}
if (platform === 'android' && !ANDROID_BUILD_TYPES.includes(type)) {
consola.error(`Invalid build type for Android. Supported values are: ${ANDROID_BUILD_TYPES.map((t) => `\`${t}\``).join(', ')}.`);
process.exit(1);
}
// Prompt for environment if not provided
if (!environment && 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 && 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 });
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 })),
});
}
}
}
// Create the app build
if (!json) {
consola.start('Creating build...');
}
const response = await appBuildsService.create({
appCertificateName: certificate,
appEnvironmentName: environment,
appId,
stack,
gitRef,
platform,
type,
});
if (!json) {
consola.success(`Build created successfully.`);
consola.info(`Build Number: ${response.numberAsString}`);
consola.info(`Build ID: ${response.id}`);
consola.info(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${response.id}`);
}
// Wait for build job to complete by default, unless --detached flag is set
const shouldWait = !options.detached;
if (shouldWait) {
let lastPrintedLogNumber = 0;
let isWaitingForStart = true;
// Poll build status until completion
while (true) {
try {
const build = await appBuildsService.findOne({
appId,
appBuildId: response.id,
relations: 'appBuildArtifacts,job,job.jobLogs',
});
if (!build.job) {
await wait(3000);
continue;
}
const jobStatus = build.job.status;
// Show spinner while queued or pending
if (jobStatus === 'queued' || jobStatus === 'pending') {
if (isWaitingForStart && !json) {
consola.start(`Waiting for build to start (status: ${jobStatus})...`);
}
await wait(3000);
continue;
}
// Stop spinner when job moves to in_progress
if (isWaitingForStart && jobStatus === 'in_progress') {
isWaitingForStart = false;
if (!json) {
consola.success('Build started...');
}
}
// Print new logs
if (!json && build.job.jobLogs && build.job.jobLogs.length > 0) {
const newLogs = build.job.jobLogs
.filter((log) => log.number > lastPrintedLogNumber)
.sort((a, b) => a.number - b.number);
for (const log of newLogs) {
console.log(unescapeAnsi(log.payload));
lastPrintedLogNumber = log.number;
}
}
// Handle terminal states
if (jobStatus === 'succeeded' ||
jobStatus === 'failed' ||
jobStatus === 'canceled' ||
jobStatus === 'rejected' ||
jobStatus === 'timed_out') {
if (!json) {
console.log(); // New line for better readability
}
if (jobStatus === 'succeeded') {
if (!json) {
consola.success('Build completed successfully.');
console.log(); // New line for better readability
}
// Download artifacts if flags are set
if (options.apk && platform === 'android') {
await handleArtifactDownload({
appId,
buildId: response.id,
buildArtifacts: build.appBuildArtifacts,
artifactType: 'apk',
filePath: typeof options.apk === 'string' ? options.apk : undefined,
json,
});
}
if (options.aab && platform === 'android') {
await handleArtifactDownload({
appId,
buildId: response.id,
buildArtifacts: build.appBuildArtifacts,
artifactType: 'aab',
filePath: typeof options.aab === 'string' ? options.aab : undefined,
json,
});
}
if (options.ipa && platform === 'ios') {
await handleArtifactDownload({
appId,
buildId: response.id,
buildArtifacts: build.appBuildArtifacts,
artifactType: 'ipa',
filePath: typeof options.ipa === 'string' ? options.ipa : undefined,
json,
});
}
// Output JSON if json flag is set
if (json) {
console.log(JSON.stringify({
id: response.id,
numberAsString: response.numberAsString,
}, null, 2));
}
// Exit successfully
process.exit(0);
}
else if (jobStatus === 'failed') {
consola.error('Build failed.');
process.exit(1);
}
else if (jobStatus === 'canceled') {
consola.warn('Build was canceled.');
process.exit(1);
}
else if (jobStatus === 'rejected') {
consola.error('Build was rejected.');
process.exit(1);
}
else if (jobStatus === 'timed_out') {
consola.error('Build timed out.');
process.exit(1);
}
}
// Wait before next poll (3 seconds)
await wait(3000);
}
catch (error) {
consola.error('Error polling build status:', error);
process.exit(1);
}
}
}
else {
if (json) {
console.log(JSON.stringify({
id: response.id,
numberAsString: response.numberAsString,
}, null, 2));
}
}
},
});
/**
* Download a build artifact (APK, AAB, or IPA).
*/
const handleArtifactDownload = async (options) => {
const { appId, buildId, buildArtifacts, artifactType, filePath, json } = options;
try {
const artifactTypeUpper = artifactType.toUpperCase();
if (!json) {
consola.start(`Downloading ${artifactTypeUpper}...`);
}
// Find the artifact
const artifact = buildArtifacts?.find((artifact) => artifact.type === artifactType);
if (!artifact) {
if (!json) {
consola.warn(`No ${artifactTypeUpper} artifact found for this build.`);
}
return;
}
if (artifact.status !== 'ready') {
if (!json) {
consola.warn(`${artifactTypeUpper} artifact is not ready (status: ${artifact.status}).`);
}
return;
}
// Download the artifact
const artifactData = await appBuildsService.downloadArtifact({
appId,
appBuildId: buildId,
artifactId: artifact.id,
});
// Determine the file path
let outputPath;
if (filePath) {
// Use provided path (can be relative or absolute)
outputPath = path.resolve(filePath);
}
else {
// Default to current working directory with build ID as filename
outputPath = path.resolve(`${buildId}.${artifactType}`);
}
// Save the file
await fs.writeFile(outputPath, Buffer.from(artifactData));
if (!json) {
consola.success(`${artifactTypeUpper} downloaded successfully: ${outputPath}`);
}
}
catch (error) {
consola.error(`Failed to download ${artifactType.toUpperCase()}:`, error);
}
};