UNPKG

@capawesome/cli

Version:

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

441 lines (440 loc) 21.1 kB
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 appEnvironmentsService from '../../../services/app-environments.js'; import { parseKeyValuePairs } from '../../../utils/app-environments.js'; import { withAuth } from '../../../utils/auth.js'; import { createBufferFromPath } from '../../../utils/buffer.js'; import { isInteractive } from '../../../utils/environment.js'; import { isDirectory, 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'; 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 on Capawesome Cloud Runners.', 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.'), channel: z.string().optional().describe('The name of the channel to deploy to (Web only).'), destination: z.string().optional().describe('The name of the destination to deploy to (Android/iOS only).'), 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.'), path: z.string().optional().describe('Path to local source files to upload. Must be a folder or a zip file.'), platform: z .enum(['ios', 'android', 'web'], { message: 'Platform must be either `ios`, `android`, or `web`.', }) .optional() .describe('The platform for the build. Supported values are `ios`, `android`, and `web`.'), 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.'), 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`. For Web, no type is required.'), 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.'), zip: z .union([z.boolean(), z.string()]) .optional() .describe('Download the generated ZIP file (Web only). Optionally provide a file path.'), yes: z.boolean().optional().describe('Skip confirmation prompts.'), }), { y: 'yes' }), action: withAuth(async (options) => { let { appId, platform, type, gitRef, environment, certificate, json, stack, path: sourcePath, url } = options; // Validate that detached flag cannot be used with artifact flags if (options.detached && (options.apk || options.aab || options.ipa || options.zip)) { consola.error('The --detached flag cannot be used with --apk, --aab, --ipa, or --zip flags.'); process.exit(1); } // Validate that detached flag cannot be used with channel or destination if (options.detached && (options.channel || options.destination)) { consola.error('The --detached flag cannot be used with --channel or --destination flags.'); process.exit(1); } // Validate that channel and destination cannot be used together if (options.channel && options.destination) { consola.error('The --channel and --destination flags cannot be used together.'); process.exit(1); } // 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 pathReadable = await isReadable(resolvedPath); if (!pathReadable) { consola.error(`The path does not exist or is not accessible: ${resolvedPath}`); process.exit(1); } const pathIsDirectory = await isDirectory(resolvedPath); if (pathIsDirectory) { const packageJsonPath = path.join(resolvedPath, 'package.json'); const packageJsonReadable = await isReadable(packageJsonPath); if (!packageJsonReadable) { consola.error(`The path must contain a package.json file: ${packageJsonPath}`); process.exit(1); } } else if (!zip.isZipped(resolvedPath)) { consola.error(`The path must be a folder or a zip file: ${resolvedPath}`); 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 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' }, { label: 'Web', value: 'web' }, ], }); if (!platform) { consola.error('You must select a platform.'); process.exit(1); } } // 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); } } // Set default type based on platform if not provided if (!type) { if (platform === 'android') { type = 'debug'; } else if (platform === 'ios') { type = '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); } // Validate that channel is only used with web platform if (options.channel && platform !== 'web') { consola.error('The --channel flag can only be used with the web platform.'); process.exit(1); } // Validate that destination is only used with non-web platforms if (options.destination && platform === 'web') { consola.error('The --destination flag cannot be used with the web platform.'); process.exit(1); } // 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 }); 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); const sourcePathIsDirectory = await isDirectory(resolvedPath); let buffer; if (sourcePathIsDirectory) { consola.start('Zipping source files...'); buffer = await zip.zipFolderWithGitignore(resolvedPath); } else { buffer = await createBufferFromPath(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 app build consola.start('Creating build...'); const response = await appBuildsService.create({ adHocEnvironmentVariables, appBuildSourceId, appCertificateName: certificate, appEnvironmentName: environment, appId, stack, gitRef, platform, type, }); 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 job to complete by default, unless --detached flag is set const shouldWait = !options.detached; if (shouldWait) { await waitForJobCompletion({ jobId: response.jobId }); const appBuild = await appBuildsService.findOne({ appId, appBuildId: response.id, relations: 'appBuildArtifacts', }); 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 completed successfully.'); console.log(); // Download artifacts if flags are set if (options.apk && platform === 'android') { await handleArtifactDownload({ appId, buildId: response.id, buildArtifacts: appBuild.appBuildArtifacts, artifactType: 'apk', filePath: typeof options.apk === 'string' ? options.apk : undefined, }); } if (options.aab && platform === 'android') { await handleArtifactDownload({ appId, buildId: response.id, buildArtifacts: appBuild.appBuildArtifacts, artifactType: 'aab', filePath: typeof options.aab === 'string' ? options.aab : undefined, }); } if (options.ipa && platform === 'ios') { await handleArtifactDownload({ appId, buildId: response.id, buildArtifacts: appBuild.appBuildArtifacts, artifactType: 'ipa', filePath: typeof options.ipa === 'string' ? options.ipa : undefined, }); } if (options.zip && platform === 'web') { await handleArtifactDownload({ appId, buildId: response.id, buildArtifacts: appBuild.appBuildArtifacts, artifactType: 'zip', filePath: typeof options.zip === 'string' ? options.zip : undefined, }); } // Output JSON if json flag is set if (json) { console.log(JSON.stringify({ id: response.id, numberAsString: response.numberAsString, }, null, 2)); } } else { if (json) { console.log(JSON.stringify({ id: response.id, numberAsString: response.numberAsString, }, null, 2)); } else { 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 completed successfully.`); } } // Create deployment if channel or destination is set if (options.channel || options.destination) { await (await import('../../../commands/apps/deployments/create.js').then((mod) => mod.default)).action({ appId, buildId: response.id, channel: options.channel, destination: options.destination }, undefined); } }), }); /** * Download a build artifact (APK, AAB, IPA, or ZIP). */ const handleArtifactDownload = async (options) => { const { appId, buildId, buildArtifacts, artifactType, filePath } = options; try { const artifactTypeUpper = artifactType.toUpperCase(); consola.start(`Downloading ${artifactTypeUpper}...`); // Find the artifact const artifact = buildArtifacts?.find((artifact) => artifact.type === artifactType); if (!artifact) { consola.warn(`No ${artifactTypeUpper} artifact found for this build.`); return; } if (artifact.status !== 'ready') { 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)); consola.success(`${artifactTypeUpper} downloaded successfully: ${outputPath}`); } catch (error) { consola.error(`Failed to download ${artifactType.toUpperCase()}:`, error); } };