@capawesome/cli
Version:
The Capawesome Cloud Command Line Interface (CLI) to manage Live Updates and more.
248 lines (247 loc) • 11.3 kB
JavaScript
import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
import appBundlesService from '../../../services/app-bundles.js';
import appsService from '../../../services/apps.js';
import { withAuth } from '../../../utils/auth.js';
import { parseCustomProperties } from '../../../utils/custom-properties.js';
import { createBufferFromPath, createBufferFromString, isPrivateKeyContent } from '../../../utils/buffer.js';
import { isInteractive } from '../../../utils/environment.js';
import { isReadable } from '../../../utils/file.js';
import { createHash } from '../../../utils/hash.js';
import { formatPrivateKey } from '../../../utils/private-key.js';
import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
import { createSignature } from '../../../utils/signature.js';
import zip from '../../../utils/zip.js';
import { defineCommand, defineOptions } from '@robingenz/zli';
import consola from 'consola';
import { z } from 'zod';
export default defineCommand({
description: 'Register a self-hosted bundle URL for serving artifacts from your own infrastructure.',
options: defineOptions(z.object({
androidMax: z.coerce
.string()
.optional()
.describe('The maximum Android version code (`versionCode`) that the bundle supports.'),
androidMin: z.coerce
.string()
.optional()
.describe('The minimum Android version code (`versionCode`) that the bundle supports.'),
androidEq: z.coerce
.string()
.optional()
.describe('The exact Android version code (`versionCode`) that the bundle does not support.'),
appId: z
.string({
message: 'App ID must be a UUID.',
})
.uuid({
message: 'App ID must be a UUID.',
})
.optional()
.describe('App ID to deploy to.'),
channel: z.string().optional().describe('Channel to associate the bundle with.'),
commitMessage: z
.string()
.optional()
.describe('The commit message related to the bundle. Deprecated, use `--git-ref` instead.'),
commitRef: z
.string()
.optional()
.describe('The commit ref related to the bundle. Deprecated, use `--git-ref` instead.'),
commitSha: z
.string()
.optional()
.describe('The commit sha related to the bundle. Deprecated, use `--git-ref` instead.'),
customProperty: z
.array(z.string().min(1).max(100))
.max(10)
.optional()
.describe('A custom property to assign to the bundle. Must be in the format `key=value`. Can be specified multiple times.'),
expiresInDays: z.coerce
.number({
message: 'Expiration days must be an integer.',
})
.int({
message: 'Expiration days must be an integer.',
})
.optional()
.describe('The number of days until the bundle is automatically deleted.'),
gitRef: z
.string()
.optional()
.describe('The Git reference (branch, tag, or commit SHA) to associate with the bundle.'),
iosMax: z
.string()
.optional()
.describe('The maximum iOS bundle version (`CFBundleVersion`) that the bundle supports.'),
iosMin: z
.string()
.optional()
.describe('The minimum iOS bundle version (`CFBundleVersion`) that the bundle supports.'),
iosEq: z
.string()
.optional()
.describe('The exact iOS bundle version (`CFBundleVersion`) that the bundle does not support.'),
path: z.string().optional().describe('Path to zip file for code signing only.'),
privateKey: z
.string()
.optional()
.describe('The private key to sign the bundle with. Can be a file path to a .pem file or the private key content as plain text.'),
rolloutPercentage: z.coerce
.number()
.int({
message: 'Percentage must be an integer.',
})
.min(0, {
message: 'Percentage must be at least 0.',
})
.max(100, {
message: 'Percentage must be at most 100.',
})
.optional()
.describe('The percentage of devices to deploy the bundle to. Must be an integer between 0 and 100.'),
url: z.string().optional().describe('The url to the self-hosted bundle file.'),
yes: z.boolean().optional().describe('Skip confirmation prompts.'),
}), { y: 'yes' }),
action: withAuth(async (options, args) => {
let { androidEq, androidMax, androidMin, appId, channel, commitMessage, commitRef, commitSha, customProperty, expiresInDays, gitRef, iosEq, iosMax, iosMin, path, privateKey, rolloutPercentage, url, } = options;
if (expiresInDays) {
consola.warn('The `--expires-in-days` option is deprecated and will be removed in a future version. Bundle expiration is now managed by the data retention policy of your organization billing plan.');
}
// Prompt for url if not provided
if (!url) {
if (!isInteractive()) {
consola.error('You must provide a url when running in non-interactive environment.');
process.exit(1);
}
else {
url = await prompt('Enter the URL to the self-hosted bundle file:', {
type: 'text',
});
if (!url) {
consola.error('You must provide a url to the self-hosted bundle file.');
process.exit(1);
}
}
}
// Prompt for appId 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 channel if interactive
if (!channel && !options.yes && isInteractive()) {
const shouldDeployToChannel = await prompt('Do you want to deploy to a specific channel?', {
type: 'confirm',
initial: false,
});
if (shouldDeployToChannel) {
channel = await prompt('Enter the channel name:', {
type: 'text',
});
if (!channel) {
consola.error('The channel name must be at least one character long.');
process.exit(1);
}
}
}
// Handle checksum and signature generation if path is provided
let checksum;
let signature;
if (path) {
// Validate that path is a zip file
if (!zip.isZipped(path)) {
consola.error('The path must be a zip file when providing a URL.');
process.exit(1);
}
// Check if the path exists
const pathReadable = await isReadable(path);
if (!pathReadable) {
consola.error(`The path does not exist or is not accessible: ${path}`);
process.exit(1);
}
// Create the file buffer
const fileBuffer = await createBufferFromPath(path);
// Generate checksum
checksum = await createHash(fileBuffer);
// Handle private key for signing
if (privateKey) {
let privateKeyBuffer;
if (isPrivateKeyContent(privateKey)) {
// Handle plain text private key content
const formattedPrivateKey = formatPrivateKey(privateKey);
privateKeyBuffer = createBufferFromString(formattedPrivateKey);
}
else if (privateKey.endsWith('.pem')) {
// Handle file path
const privateKeyReadable = await isReadable(privateKey);
if (privateKeyReadable) {
const keyBuffer = await createBufferFromPath(privateKey);
const keyContent = keyBuffer.toString('utf8');
const formattedPrivateKey = formatPrivateKey(keyContent);
privateKeyBuffer = createBufferFromString(formattedPrivateKey);
}
else {
consola.error(`The private key file does not exist or is not accessible: ${privateKey}`);
process.exit(1);
}
}
else {
consola.error('Private key must be either a path to a .pem file or the private key content as plain text.');
process.exit(1);
}
// Sign the bundle
try {
signature = await createSignature(privateKeyBuffer, fileBuffer);
}
catch {
consola.error('Failed to parse the private key. Make sure the private key is a valid PEM-formatted key and is not encrypted.');
process.exit(1);
}
}
}
// Get app details for confirmation
const app = await appsService.findOne({ appId });
const appName = app.name;
// Final confirmation before registering bundle
if (!options.yes && isInteractive()) {
const confirmed = await prompt(`Are you sure you want to register bundle from URL "${url}" for app "${appName}" (${appId})?`, {
type: 'confirm',
});
if (!confirmed) {
consola.info('Bundle registration cancelled.');
process.exit(0);
}
}
// Create the app bundle
consola.start('Registering bundle...');
const response = await appBundlesService.create({
appId,
artifactType: 'zip',
channelName: channel,
checksum,
eqAndroidAppVersionCode: androidEq,
eqIosAppVersionCode: iosEq,
gitCommitMessage: commitMessage,
gitCommitRef: commitRef,
gitCommitSha: commitSha,
gitRef,
customProperties: parseCustomProperties(customProperty),
url,
maxAndroidAppVersionCode: androidMax,
maxIosAppVersionCode: iosMax,
minAndroidAppVersionCode: androidMin,
minIosAppVersionCode: iosMin,
rolloutPercentage: (rolloutPercentage ?? 100) / 100,
signature,
});
consola.info(`Bundle Artifact ID: ${response.id}`);
if (response.appDeploymentId) {
consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${response.appDeploymentId}`);
}
consola.success('Live Update successfully registered.');
}),
});