@capawesome/cli 3.11.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/commands/apps/builds/cancel.js +1 -1
  3. package/dist/commands/apps/builds/create.js +58 -50
  4. package/dist/commands/apps/builds/download.js +27 -3
  5. package/dist/commands/apps/bundles/create.js +5 -449
  6. package/dist/commands/apps/bundles/delete.js +3 -68
  7. package/dist/commands/apps/bundles/update.js +3 -66
  8. package/dist/commands/apps/channels/create.js +5 -8
  9. package/dist/commands/apps/channels/create.test.js +6 -9
  10. package/dist/commands/apps/channels/delete.js +3 -2
  11. package/dist/commands/apps/channels/get.js +2 -12
  12. package/dist/commands/apps/channels/get.test.js +1 -2
  13. package/dist/commands/apps/channels/list.js +2 -10
  14. package/dist/commands/apps/channels/list.test.js +2 -3
  15. package/dist/commands/apps/channels/pause.js +85 -0
  16. package/dist/commands/apps/channels/resume.js +85 -0
  17. package/dist/commands/apps/channels/update.js +4 -7
  18. package/dist/commands/apps/channels/update.test.js +2 -4
  19. package/dist/commands/apps/create.js +1 -1
  20. package/dist/commands/apps/delete.js +3 -2
  21. package/dist/commands/apps/deployments/cancel.js +1 -1
  22. package/dist/commands/apps/deployments/create.js +82 -31
  23. package/dist/commands/apps/devices/delete.js +3 -2
  24. package/dist/commands/apps/environments/create.js +1 -1
  25. package/dist/commands/apps/environments/delete.js +3 -2
  26. package/dist/commands/apps/liveupdates/bundle.js +117 -0
  27. package/dist/commands/apps/liveupdates/generate-manifest.js +39 -0
  28. package/dist/commands/{manifests/generate.test.js → apps/liveupdates/generate-manifest.test.js} +6 -6
  29. package/dist/commands/apps/liveupdates/register.js +291 -0
  30. package/dist/commands/apps/{bundles/create.test.js → liveupdates/register.test.js} +123 -111
  31. package/dist/commands/apps/liveupdates/rollback.js +171 -0
  32. package/dist/commands/apps/liveupdates/rollout.js +147 -0
  33. package/dist/commands/apps/liveupdates/upload.js +420 -0
  34. package/dist/commands/apps/liveupdates/upload.test.js +325 -0
  35. package/dist/commands/manifests/generate.js +2 -27
  36. package/dist/commands/organizations/create.js +1 -1
  37. package/dist/index.js +8 -0
  38. package/dist/services/app-builds.js +9 -2
  39. package/dist/services/app-channels.js +19 -0
  40. package/dist/services/app-deployments.js +24 -14
  41. package/dist/services/config.js +2 -0
  42. package/dist/utils/app-environments.js +2 -1
  43. package/dist/utils/time-format.js +26 -0
  44. package/package.json +3 -3
  45. package/dist/commands/apps/bundles/delete.test.js +0 -142
  46. package/dist/commands/apps/bundles/update.test.js +0 -144
  47. package/dist/utils/capacitor-config.js +0 -96
  48. package/dist/utils/package-json.js +0 -58
@@ -0,0 +1,147 @@
1
+ import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
2
+ import appChannelsService from '../../../services/app-channels.js';
3
+ import appDeploymentsService from '../../../services/app-deployments.js';
4
+ import appsService from '../../../services/apps.js';
5
+ import authorizationService from '../../../services/authorization-service.js';
6
+ import organizationsService from '../../../services/organizations.js';
7
+ import { isInteractive } from '../../../utils/environment.js';
8
+ import { prompt } from '../../../utils/prompt.js';
9
+ import { defineCommand, defineOptions } from '@robingenz/zli';
10
+ import consola from 'consola';
11
+ import { z } from 'zod';
12
+ export default defineCommand({
13
+ description: 'Update the rollout percentage of the active build in a channel.',
14
+ options: defineOptions(z.object({
15
+ appId: z
16
+ .uuid({
17
+ message: 'App ID must be a UUID.',
18
+ })
19
+ .optional()
20
+ .describe('App ID of the channel.'),
21
+ channel: z.string().optional().describe('Name of the channel to update rollout for.'),
22
+ percentage: z.coerce
23
+ .number()
24
+ .int({
25
+ message: 'Percentage must be an integer.',
26
+ })
27
+ .min(0, {
28
+ message: 'Percentage must be at least 0.',
29
+ })
30
+ .max(100, {
31
+ message: 'Percentage must be at most 100.',
32
+ })
33
+ .optional()
34
+ .describe('Rollout percentage (0-100).'),
35
+ })),
36
+ action: async (options) => {
37
+ let { appId, channel, percentage } = options;
38
+ // Check if the user is logged in
39
+ if (!authorizationService.hasAuthorizationToken()) {
40
+ consola.error('You must be logged in to run this command. Please run the `login` command first.');
41
+ process.exit(1);
42
+ }
43
+ // Prompt for app ID if not provided
44
+ if (!appId) {
45
+ if (!isInteractive()) {
46
+ consola.error('You must provide an app ID when running in non-interactive environment.');
47
+ process.exit(1);
48
+ }
49
+ const organizations = await organizationsService.findAll();
50
+ if (organizations.length === 0) {
51
+ consola.error('You must create an organization before updating a rollout percentage.');
52
+ process.exit(1);
53
+ }
54
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
55
+ const organizationId = await prompt('Select the organization of the app for which you want to update the rollout percentage.', {
56
+ type: 'select',
57
+ options: organizations.map((organization) => ({ label: organization.name, value: organization.id })),
58
+ });
59
+ if (!organizationId) {
60
+ consola.error('You must select the organization of an app for which you want to update the rollout percentage.');
61
+ process.exit(1);
62
+ }
63
+ const apps = await appsService.findAll({
64
+ organizationId,
65
+ });
66
+ if (apps.length === 0) {
67
+ consola.error('You must create an app before updating a rollout percentage.');
68
+ process.exit(1);
69
+ }
70
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
71
+ appId = await prompt('Which app do you want to update the rollout percentage for:', {
72
+ type: 'select',
73
+ options: apps.map((app) => ({ label: app.name, value: app.id })),
74
+ });
75
+ if (!appId) {
76
+ consola.error('You must select an app to update the rollout percentage for.');
77
+ process.exit(1);
78
+ }
79
+ }
80
+ // Prompt for channel name if not provided
81
+ if (!channel) {
82
+ if (!isInteractive()) {
83
+ consola.error('You must provide a channel when running in non-interactive environment.');
84
+ process.exit(1);
85
+ }
86
+ channel = await prompt('Enter the channel name to update rollout for:', {
87
+ type: 'text',
88
+ });
89
+ if (!channel) {
90
+ consola.error('You must enter a channel name to update rollout for.');
91
+ process.exit(1);
92
+ }
93
+ }
94
+ // Fetch channel by name
95
+ const appChannels = await appChannelsService.findAll({ appId, name: channel });
96
+ if (appChannels.length === 0) {
97
+ consola.error(`Channel not found.`);
98
+ process.exit(1);
99
+ }
100
+ const appChannelId = appChannels[0]?.id;
101
+ if (!appChannelId) {
102
+ consola.error('Channel ID is missing.');
103
+ process.exit(1);
104
+ }
105
+ // Fetch channel with deployment relation
106
+ const appChannel = await appChannelsService.findOneById({
107
+ appId,
108
+ id: appChannelId,
109
+ relations: 'appDeployment',
110
+ });
111
+ // Validate that the channel has an active build assigned
112
+ if (!appChannel.appDeployment) {
113
+ consola.error('Channel has no active build assigned.');
114
+ process.exit(1);
115
+ }
116
+ // Prompt for percentage if not provided
117
+ if (percentage === undefined) {
118
+ if (!isInteractive()) {
119
+ consola.error('You must provide --percentage when running in non-interactive environment.');
120
+ process.exit(1);
121
+ }
122
+ const percentageInput = await prompt('Enter the rollout percentage (0-100):', {
123
+ type: 'text',
124
+ });
125
+ if (!percentageInput) {
126
+ consola.error('You must enter a rollout percentage.');
127
+ process.exit(1);
128
+ }
129
+ percentage = parseInt(percentageInput, 10);
130
+ if (isNaN(percentage) || percentage < 0 || percentage > 100) {
131
+ consola.error('Percentage must be a number between 0 and 100.');
132
+ process.exit(1);
133
+ }
134
+ }
135
+ // Update deployment rollout percentage
136
+ consola.start('Updating rollout percentage...');
137
+ const response = await appDeploymentsService.update({
138
+ appId,
139
+ appDeploymentId: appChannel.appDeployment.id,
140
+ // Convert percentage from 0-100 to 0-1 for API
141
+ rolloutPercentage: percentage / 100,
142
+ });
143
+ consola.info(`Deployment ID: ${response.id}`);
144
+ consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${response.id}`);
145
+ consola.success(`Rolled out to ${percentage}%.`);
146
+ },
147
+ });
@@ -0,0 +1,420 @@
1
+ import { DEFAULT_CONSOLE_BASE_URL, MAX_CONCURRENT_UPLOADS } from '../../../config/index.js';
2
+ import appBundleFilesService from '../../../services/app-bundle-files.js';
3
+ import appBundlesService from '../../../services/app-bundles.js';
4
+ import appsService from '../../../services/apps.js';
5
+ import authorizationService from '../../../services/authorization-service.js';
6
+ import organizationsService from '../../../services/organizations.js';
7
+ import { createBufferFromPath, createBufferFromReadStream, createBufferFromString, isPrivateKeyContent, } from '../../../utils/buffer.js';
8
+ import { isInteractive } from '../../../utils/environment.js';
9
+ import { fileExistsAtPath, getFilesInDirectoryAndSubdirectories, isDirectory } from '../../../utils/file.js';
10
+ import { createHash } from '../../../utils/hash.js';
11
+ import { generateManifestJson } from '../../../utils/manifest.js';
12
+ import { formatPrivateKey } from '../../../utils/private-key.js';
13
+ import { prompt } from '../../../utils/prompt.js';
14
+ import { createSignature } from '../../../utils/signature.js';
15
+ import zip from '../../../utils/zip.js';
16
+ import { defineCommand, defineOptions } from '@robingenz/zli';
17
+ import consola from 'consola';
18
+ import { createReadStream } from 'fs';
19
+ import pathModule from 'path';
20
+ import { z } from 'zod';
21
+ export default defineCommand({
22
+ description: 'Upload a bundle to Capawesome Cloud.',
23
+ options: defineOptions(z.object({
24
+ androidMax: z.coerce
25
+ .string()
26
+ .optional()
27
+ .describe('The maximum Android version code (`versionCode`) that the bundle supports.'),
28
+ androidMin: z.coerce
29
+ .string()
30
+ .optional()
31
+ .describe('The minimum Android version code (`versionCode`) that the bundle supports.'),
32
+ androidEq: z.coerce
33
+ .string()
34
+ .optional()
35
+ .describe('The exact Android version code (`versionCode`) that the bundle does not support.'),
36
+ appId: z
37
+ .string({
38
+ message: 'App ID must be a UUID.',
39
+ })
40
+ .uuid({
41
+ message: 'App ID must be a UUID.',
42
+ })
43
+ .optional()
44
+ .describe('App ID to deploy to.'),
45
+ artifactType: z
46
+ .enum(['manifest', 'zip'], {
47
+ message: 'Invalid artifact type. Must be either `manifest` or `zip`.',
48
+ })
49
+ .optional()
50
+ .describe('The type of artifact to deploy. Must be either `manifest` or `zip`. The default is `zip`.')
51
+ .default('zip'),
52
+ channel: z.string().optional().describe('Channel to associate the bundle with.'),
53
+ commitMessage: z
54
+ .string()
55
+ .optional()
56
+ .describe('The commit message related to the bundle. Deprecated, use `--git-ref` instead.'),
57
+ commitRef: z
58
+ .string()
59
+ .optional()
60
+ .describe('The commit ref related to the bundle. Deprecated, use `--git-ref` instead.'),
61
+ commitSha: z
62
+ .string()
63
+ .optional()
64
+ .describe('The commit sha related to the bundle. Deprecated, use `--git-ref` instead.'),
65
+ customProperty: z
66
+ .array(z.string().min(1).max(100))
67
+ .optional()
68
+ .describe('A custom property to assign to the bundle. Must be in the format `key=value`. Can be specified multiple times.'),
69
+ expiresInDays: z.coerce
70
+ .number({
71
+ message: 'Expiration days must be an integer.',
72
+ })
73
+ .int({
74
+ message: 'Expiration days must be an integer.',
75
+ })
76
+ .optional()
77
+ .describe('The number of days until the bundle is automatically deleted.'),
78
+ gitRef: z
79
+ .string()
80
+ .optional()
81
+ .describe('The Git reference (branch, tag, or commit SHA) to associate with the bundle.'),
82
+ iosMax: z
83
+ .string()
84
+ .optional()
85
+ .describe('The maximum iOS bundle version (`CFBundleVersion`) that the bundle supports.'),
86
+ iosMin: z
87
+ .string()
88
+ .optional()
89
+ .describe('The minimum iOS bundle version (`CFBundleVersion`) that the bundle supports.'),
90
+ iosEq: z
91
+ .string()
92
+ .optional()
93
+ .describe('The exact iOS bundle version (`CFBundleVersion`) that the bundle does not support.'),
94
+ path: z
95
+ .string()
96
+ .optional()
97
+ .describe('Path to the bundle to upload. Must be a folder (e.g. `www` or `dist`) or a zip file.'),
98
+ privateKey: z
99
+ .string()
100
+ .optional()
101
+ .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.'),
102
+ rolloutPercentage: z.coerce
103
+ .number()
104
+ .int({
105
+ message: 'Percentage must be an integer.',
106
+ })
107
+ .min(0, {
108
+ message: 'Percentage must be at least 0.',
109
+ })
110
+ .max(100, {
111
+ message: 'Percentage must be at most 100.',
112
+ })
113
+ .optional()
114
+ .describe('The percentage of devices to deploy the bundle to. Must be an integer between 0 and 100.'),
115
+ yes: z.boolean().optional().describe('Skip confirmation prompt.'),
116
+ }), { y: 'yes' }),
117
+ action: async (options, args) => {
118
+ let { androidEq, androidMax, androidMin, appId, artifactType, channel, commitMessage, commitRef, commitSha, customProperty, expiresInDays, gitRef, iosEq, iosMax, iosMin, path, privateKey, rolloutPercentage, } = options;
119
+ // Check if the user is logged in
120
+ if (!authorizationService.hasAuthorizationToken()) {
121
+ consola.error('You must be logged in to run this command. Please run the `login` command first.');
122
+ process.exit(1);
123
+ }
124
+ // Calculate the expiration date
125
+ let expiresAt;
126
+ if (expiresInDays) {
127
+ const expiresAtDate = new Date();
128
+ expiresAtDate.setDate(expiresAtDate.getDate() + expiresInDays);
129
+ expiresAt = expiresAtDate.toISOString();
130
+ }
131
+ // Prompt for path if not provided
132
+ if (!path) {
133
+ if (!isInteractive()) {
134
+ consola.error('You must provide a path when running in non-interactive environment.');
135
+ process.exit(1);
136
+ }
137
+ consola.warn('Make sure you have built your web assets before uploading (e.g., `npm run build`).');
138
+ path = await prompt('Enter the path to the web assets directory (e.g., `dist` or `www`):', {
139
+ type: 'text',
140
+ });
141
+ if (!path) {
142
+ consola.error('You must provide a path to the app bundle.');
143
+ process.exit(1);
144
+ }
145
+ }
146
+ // Validate the provided path
147
+ const pathExists = await fileExistsAtPath(path);
148
+ if (!pathExists) {
149
+ consola.error(`The path does not exist.`);
150
+ process.exit(1);
151
+ }
152
+ // Check if the directory contains an index.html file
153
+ const pathIsDirectory = await isDirectory(path);
154
+ if (pathIsDirectory) {
155
+ const files = await getFilesInDirectoryAndSubdirectories(path);
156
+ const indexHtml = files.find((file) => file.href === 'index.html');
157
+ if (!indexHtml) {
158
+ consola.error('The directory must contain an `index.html` file.');
159
+ process.exit(1);
160
+ }
161
+ }
162
+ else if (zip.isZipped(path)) {
163
+ // No-op
164
+ }
165
+ else {
166
+ consola.error('The path must be either a folder or a zip file.');
167
+ process.exit(1);
168
+ }
169
+ // Check that the path is a directory when creating a bundle with an artifact type of manifest
170
+ if (artifactType === 'manifest') {
171
+ const pathIsDirectory = await isDirectory(path);
172
+ if (!pathIsDirectory) {
173
+ consola.error('The path must be a folder when creating a bundle with an artifact type of `manifest`.');
174
+ process.exit(1);
175
+ }
176
+ }
177
+ // Prompt for appId if not provided
178
+ if (!appId) {
179
+ if (!isInteractive()) {
180
+ consola.error('You must provide an app ID when running in non-interactive environment.');
181
+ process.exit(1);
182
+ }
183
+ const organizations = await organizationsService.findAll();
184
+ if (organizations.length === 0) {
185
+ consola.error('You must create an organization before creating a bundle.');
186
+ process.exit(1);
187
+ }
188
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
189
+ const organizationId = await prompt('Select the organization of the app for which you want to create a bundle.', {
190
+ type: 'select',
191
+ options: organizations.map((organization) => ({ label: organization.name, value: organization.id })),
192
+ });
193
+ if (!organizationId) {
194
+ consola.error('You must select the organization of an app for which you want to create a bundle.');
195
+ process.exit(1);
196
+ }
197
+ const apps = await appsService.findAll({
198
+ organizationId,
199
+ });
200
+ if (apps.length === 0) {
201
+ consola.error('You must create an app before creating a bundle.');
202
+ process.exit(1);
203
+ }
204
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
205
+ appId = await prompt('Which app do you want to deploy to:', {
206
+ type: 'select',
207
+ options: apps.map((app) => ({ label: app.name, value: app.id })),
208
+ });
209
+ if (!appId) {
210
+ consola.error('You must select an app to deploy to.');
211
+ process.exit(1);
212
+ }
213
+ }
214
+ // Prompt for channel if interactive
215
+ if (!channel && !options.yes && isInteractive()) {
216
+ const shouldDeployToChannel = await prompt('Do you want to deploy to a specific channel?', {
217
+ type: 'confirm',
218
+ initial: false,
219
+ });
220
+ if (shouldDeployToChannel) {
221
+ channel = await prompt('Enter the channel name:', {
222
+ type: 'text',
223
+ });
224
+ if (!channel) {
225
+ consola.error('The channel name must be at least one character long.');
226
+ process.exit(1);
227
+ }
228
+ }
229
+ }
230
+ // Create the private key buffer
231
+ let privateKeyBuffer;
232
+ if (privateKey) {
233
+ if (isPrivateKeyContent(privateKey)) {
234
+ // Handle plain text private key content
235
+ const formattedPrivateKey = formatPrivateKey(privateKey);
236
+ privateKeyBuffer = createBufferFromString(formattedPrivateKey);
237
+ }
238
+ else if (privateKey.endsWith('.pem')) {
239
+ // Handle file path
240
+ const fileExists = await fileExistsAtPath(privateKey);
241
+ if (fileExists) {
242
+ const keyBuffer = await createBufferFromPath(privateKey);
243
+ const keyContent = keyBuffer.toString('utf8');
244
+ const formattedPrivateKey = formatPrivateKey(keyContent);
245
+ privateKeyBuffer = createBufferFromString(formattedPrivateKey);
246
+ }
247
+ else {
248
+ consola.error('Private key file not found.');
249
+ process.exit(1);
250
+ }
251
+ }
252
+ else {
253
+ consola.error('Private key must be either a path to a .pem file or the private key content as plain text.');
254
+ process.exit(1);
255
+ }
256
+ }
257
+ // Get app details for confirmation
258
+ const app = await appsService.findOne({ appId });
259
+ const appName = app.name;
260
+ // Final confirmation before uploading
261
+ if (!options.yes && isInteractive()) {
262
+ const relativePath = pathModule.relative(process.cwd(), path);
263
+ const confirmed = await prompt(`Are you sure you want to upload a bundle from path "${relativePath}" for app "${appName}" (${appId})?`, {
264
+ type: 'confirm',
265
+ });
266
+ if (!confirmed) {
267
+ consola.info('Bundle upload cancelled.');
268
+ process.exit(0);
269
+ }
270
+ }
271
+ // Create the app bundle
272
+ consola.start('Creating bundle...');
273
+ const response = await appBundlesService.create({
274
+ appId,
275
+ artifactType,
276
+ channelName: channel,
277
+ eqAndroidAppVersionCode: androidEq,
278
+ eqIosAppVersionCode: iosEq,
279
+ gitCommitMessage: commitMessage,
280
+ gitCommitRef: commitRef,
281
+ gitCommitSha: commitSha,
282
+ gitRef,
283
+ customProperties: parseCustomProperties(customProperty),
284
+ expiresAt,
285
+ maxAndroidAppVersionCode: androidMax,
286
+ maxIosAppVersionCode: iosMax,
287
+ minAndroidAppVersionCode: androidMin,
288
+ minIosAppVersionCode: iosMin,
289
+ // Convert percentage from 0-100 to 0-1 for API
290
+ rolloutPercentage: (rolloutPercentage ?? 100) / 100,
291
+ });
292
+ let appBundleFileId;
293
+ // Upload the app bundle files
294
+ if (artifactType === 'manifest') {
295
+ await uploadFiles({ appId, appBundleId: response.id, path, privateKeyBuffer });
296
+ }
297
+ else {
298
+ const result = await uploadZip({ appId, appBundleId: response.id, path, privateKeyBuffer });
299
+ appBundleFileId = result.appBundleFileId;
300
+ }
301
+ // Update the app bundle
302
+ consola.start('Updating bundle...');
303
+ await appBundlesService.update({
304
+ appBundleFileId,
305
+ appId,
306
+ artifactStatus: 'ready',
307
+ appBundleId: response.id,
308
+ });
309
+ consola.info(`Build Artifact ID: ${response.id}`);
310
+ if (response.appDeploymentId) {
311
+ consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${response.appDeploymentId}`);
312
+ }
313
+ consola.success('Live Update successfully uploaded.');
314
+ },
315
+ });
316
+ const uploadFile = async (options) => {
317
+ let { appId, appBundleId, buffer, href, mimeType, name, privateKeyBuffer, retryOnFailure } = options;
318
+ try {
319
+ // Generate checksum
320
+ const hash = await createHash(buffer);
321
+ // Sign the bundle
322
+ let signature;
323
+ if (privateKeyBuffer) {
324
+ signature = await createSignature(privateKeyBuffer, buffer);
325
+ }
326
+ // Create the multipart upload
327
+ return await appBundleFilesService.create({
328
+ appId,
329
+ appBundleId,
330
+ buffer,
331
+ checksum: hash,
332
+ href,
333
+ mimeType,
334
+ name,
335
+ signature,
336
+ });
337
+ }
338
+ catch (error) {
339
+ if (retryOnFailure) {
340
+ return uploadFile({
341
+ ...options,
342
+ retryOnFailure: false,
343
+ });
344
+ }
345
+ throw error;
346
+ }
347
+ };
348
+ const uploadFiles = async (options) => {
349
+ let { appId, appBundleId, path, privateKeyBuffer } = options;
350
+ // Generate the manifest file
351
+ await generateManifestJson(path);
352
+ // Get all files in the directory
353
+ const files = await getFilesInDirectoryAndSubdirectories(path);
354
+ // Iterate over each file
355
+ let fileIndex = 0;
356
+ const uploadNextFile = async () => {
357
+ if (fileIndex >= files.length) {
358
+ return;
359
+ }
360
+ const file = files[fileIndex];
361
+ fileIndex++;
362
+ consola.start(`Uploading file (${fileIndex}/${files.length})...`);
363
+ const buffer = await createBufferFromPath(file.path);
364
+ await uploadFile({
365
+ appId,
366
+ appBundleId: appBundleId,
367
+ buffer,
368
+ href: file.href,
369
+ mimeType: file.mimeType,
370
+ name: file.name,
371
+ privateKeyBuffer: privateKeyBuffer,
372
+ retryOnFailure: true,
373
+ });
374
+ await uploadNextFile();
375
+ };
376
+ const uploadPromises = Array.from({ length: MAX_CONCURRENT_UPLOADS });
377
+ for (let i = 0; i < MAX_CONCURRENT_UPLOADS; i++) {
378
+ uploadPromises[i] = uploadNextFile();
379
+ }
380
+ await Promise.all(uploadPromises);
381
+ };
382
+ const uploadZip = async (options) => {
383
+ let { appId, appBundleId, path, privateKeyBuffer } = options;
384
+ // Read the zip file
385
+ let fileBuffer;
386
+ if (zip.isZipped(path)) {
387
+ const readStream = createReadStream(path);
388
+ fileBuffer = await createBufferFromReadStream(readStream);
389
+ }
390
+ else {
391
+ consola.start('Zipping folder...');
392
+ fileBuffer = await zip.zipFolder(path);
393
+ }
394
+ // Upload the zip file
395
+ consola.start('Uploading file...');
396
+ const result = await uploadFile({
397
+ appId,
398
+ appBundleId: appBundleId,
399
+ buffer: fileBuffer,
400
+ mimeType: 'application/zip',
401
+ name: 'bundle.zip',
402
+ privateKeyBuffer: privateKeyBuffer,
403
+ });
404
+ return {
405
+ appBundleFileId: result.id,
406
+ };
407
+ };
408
+ const parseCustomProperties = (customProperty) => {
409
+ let customProperties;
410
+ if (customProperty) {
411
+ customProperties = {};
412
+ for (const property of customProperty) {
413
+ const [key, value] = property.split('=');
414
+ if (key && value) {
415
+ customProperties[key] = value;
416
+ }
417
+ }
418
+ }
419
+ return customProperties;
420
+ };