@capawesome/cli 4.21.0 → 4.23.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
4
4
 
5
+ ## [4.23.0](https://github.com/capawesome-team/cli/compare/v4.22.0...v4.23.0) (2026-09-30)
6
+
7
+
8
+ ### Features
9
+
10
+ * **apps:** add release notes and App Store submission flags ([#232](https://github.com/capawesome-team/cli/issues/232)) ([e32b057](https://github.com/capawesome-team/cli/commit/e32b057e0b06cd4e78c1d6c0dba25bceb2323e7e))
11
+ * **apps:** support Huawei AppGallery and Firebase App Distribution destinations ([#229](https://github.com/capawesome-team/cli/issues/229)) ([78d3859](https://github.com/capawesome-team/cli/commit/78d38595feebfc5d1f81ca411ec6fe1aa5f7943f))
12
+ * deploy builds to channels server-side and support multiple channels ([#210](https://github.com/capawesome-team/cli/issues/210)) ([c1722b9](https://github.com/capawesome-team/cli/commit/c1722b9d40b3d491228cedae95ee8e7a172d2cd9))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **apps:** rename Appflow tool version variables on import ([#230](https://github.com/capawesome-team/cli/issues/230)) ([b155a3a](https://github.com/capawesome-team/cli/commit/b155a3aa0eb62a67f922bb9783bf0740f345ffe0))
18
+
19
+ ## [4.22.0](https://github.com/capawesome-team/cli/compare/v4.21.0...v4.22.0) (2026-09-24)
20
+
21
+
22
+ ### Features
23
+
24
+ * **apps:** add `--app` flag to download the simulator app bundle ([#225](https://github.com/capawesome-team/cli/issues/225)) ([ee40c92](https://github.com/capawesome-team/cli/commit/ee40c92f2d73e878b7fa1f9089cdf9420daa2ae4))
25
+ * **apps:** add the `macos-golden-gate` build stack ([#226](https://github.com/capawesome-team/cli/issues/226)) ([23d4d39](https://github.com/capawesome-team/cli/commit/23d4d3961a752a6bf087f86aa9e93805a9026647))
26
+ * **apps:** download build artifacts by form factor ([#216](https://github.com/capawesome-team/cli/issues/216)) ([cd06192](https://github.com/capawesome-team/cli/commit/cd061920c69ac796e271a00141ec2284cc0ae526))
27
+
5
28
  ## [4.21.0](https://github.com/capawesome-team/cli/compare/v4.20.0...v4.21.0) (2026-09-22)
6
29
 
7
30
 
@@ -28,8 +28,8 @@ export default defineCommand({
28
28
  .optional()
29
29
  .describe('The platform for the build. Supported values are `android`, `ios`, and `web`.'),
30
30
  stack: z
31
- .enum(['macos-sequoia', 'macos-tahoe'], {
32
- message: 'Build stack must be either `macos-sequoia` or `macos-tahoe`.',
31
+ .enum(['macos-sequoia', 'macos-tahoe', 'macos-golden-gate'], {
32
+ message: 'Build stack must be one of `macos-sequoia`, `macos-tahoe`, or `macos-golden-gate`.',
33
33
  })
34
34
  .optional()
35
35
  .describe('The build stack to use for the build process.'),
@@ -1,11 +1,11 @@
1
1
  import appAutomationsService from '../../../services/app-automations.js';
2
2
  import { withAuth } from '../../../utils/auth.js';
3
+ import { clearableValue } from '../../../utils/cli-options.js';
3
4
  import { isInteractive } from '../../../utils/environment.js';
4
5
  import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
5
6
  import consola from 'consola';
6
7
  import { z } from 'zod';
7
8
  import { defineCommand, defineOptions } from 'zodline';
8
- const clearableValue = (value) => (value === '' ? null : value);
9
9
  const clearableValues = (values) => values?.filter((value) => value !== '');
10
10
  export default defineCommand({
11
11
  description: 'Update an existing app automation.',
@@ -45,8 +45,8 @@ export default defineCommand({
45
45
  .optional()
46
46
  .describe('The platform for the build. Supported values are `android`, `ios`, and `web`.'),
47
47
  stack: z
48
- .enum(['macos-sequoia', 'macos-tahoe'], {
49
- message: 'Build stack must be either `macos-sequoia` or `macos-tahoe`.',
48
+ .enum(['macos-sequoia', 'macos-tahoe', 'macos-golden-gate'], {
49
+ message: 'Build stack must be one of `macos-sequoia`, `macos-tahoe`, or `macos-golden-gate`.',
50
50
  })
51
51
  .optional()
52
52
  .describe('The build stack to use for the build process.'),
@@ -5,15 +5,18 @@ import appCertificatesService from '../../../services/app-certificates.js';
5
5
  import appConfigurationsService from '../../../services/app-configurations.js';
6
6
  import appEnvironmentsService from '../../../services/app-environments.js';
7
7
  import appsService from '../../../services/apps.js';
8
+ import { APP_BUILD_ARTIFACT_FORM_FACTORS, APP_BUILD_ARTIFACT_TYPES_BY_PLATFORM, handleAppBuildArtifactDownload, } from '../../../utils/app-build-artifacts.js';
8
9
  import { getAppBuildShareUrls } from '../../../utils/app-build-shares.js';
9
10
  import { parseKeyValuePairs } from '../../../utils/app-environments.js';
10
11
  import { withAuth } from '../../../utils/auth.js';
11
12
  import { createBufferFromPath } from '../../../utils/buffer.js';
13
+ import { parseListOption } from '../../../utils/cli-options.js';
12
14
  import { isInteractive } from '../../../utils/environment.js';
13
15
  import { offerJobFailureSummary } from '../../../utils/job-failure-summary.js';
14
16
  import { isDirectory, isReadable } from '../../../utils/file.js';
15
17
  import { waitForJobCompletion } from '../../../utils/job.js';
16
18
  import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
19
+ import { parseReleaseNotes } from '../../../utils/release-notes.js';
17
20
  import zip from '../../../utils/zip.js';
18
21
  import consola from 'consola';
19
22
  import fs from 'fs/promises';
@@ -33,6 +36,10 @@ export default defineCommand({
33
36
  .union([z.boolean(), z.string()])
34
37
  .optional()
35
38
  .describe('Download the generated APK file (Android only). Optionally provide a file path.'),
39
+ app: z
40
+ .union([z.boolean(), z.string()])
41
+ .optional()
42
+ .describe('Download the generated APP file, a zipped `.app` bundle (iOS simulator builds only). Optionally provide a file path.'),
36
43
  appId: z
37
44
  .uuid({
38
45
  message: 'App ID must be a UUID.',
@@ -40,7 +47,10 @@ export default defineCommand({
40
47
  .optional()
41
48
  .describe('App ID to create the build for.'),
42
49
  certificate: z.string().optional().describe('The name of the certificate to use for the build.'),
43
- channel: z.string().optional().describe('The name of the channel to deploy to (Web only).'),
50
+ channel: z
51
+ .array(z.string())
52
+ .optional()
53
+ .describe('The name of a channel to deploy to (Web only). Can be specified multiple times or comma-separated.'),
44
54
  configuration: z.string().optional().describe('The name of the native configuration (Android/iOS only).'),
45
55
  destination: z.string().optional().describe('The name of the destination to deploy to (Android/iOS only).'),
46
56
  detached: z
@@ -52,6 +62,12 @@ export default defineCommand({
52
62
  .boolean()
53
63
  .optional()
54
64
  .describe('Request an AI-powered failure summary (Capawesome Cloud Assist) if the build fails.'),
65
+ formFactor: z
66
+ .enum(APP_BUILD_ARTIFACT_FORM_FACTORS, {
67
+ message: 'Invalid form factor. Must be one of `mobile`, `watch`, `tv`, or `automotive`.',
68
+ })
69
+ .optional()
70
+ .describe('The form factor of the Android artifact to download with `--apk` or `--aab`. Supported values are `mobile`, `watch`, `tv`, and `automotive`. Without it, the first artifact in the order `mobile`, `watch`, `tv`, `automotive` is downloaded. The build always includes every application module.'),
55
71
  gitRef: z.string().optional().describe('The Git reference (branch, tag, or commit SHA) to build.'),
56
72
  ipa: z
57
73
  .union([z.boolean(), z.string()])
@@ -65,6 +81,18 @@ export default defineCommand({
65
81
  })
66
82
  .optional()
67
83
  .describe('The platform for the build. Supported values are `ios`, `android`, and `web`.'),
84
+ releaseNotes: z
85
+ .string()
86
+ .optional()
87
+ .describe('Release notes for the deployment (default text). Requires --destination.'),
88
+ releaseNotesFile: z
89
+ .string()
90
+ .optional()
91
+ .describe('Path to a JSON file with release notes by locale, e.g. `{"default": "…", "de-DE": "…"}`. Requires --destination.'),
92
+ releaseNotesLocale: z
93
+ .array(z.string())
94
+ .optional()
95
+ .describe('Translated release notes as `<locale>=<text>`, e.g. `de-DE=Fehlerbehebungen`. Can be specified multiple times. Requires --destination.'),
68
96
  share: z.boolean().optional().describe('Create a public share link for the build after it succeeds.'),
69
97
  shareDescription: z
70
98
  .string()
@@ -79,8 +107,8 @@ export default defineCommand({
79
107
  .optional()
80
108
  .describe('Number of days until the share link expires. Requires --share.'),
81
109
  stack: z
82
- .enum(['macos-sequoia', 'macos-tahoe'], {
83
- message: 'Build stack must be either `macos-sequoia` or `macos-tahoe`.',
110
+ .enum(['macos-sequoia', 'macos-tahoe', 'macos-golden-gate'], {
111
+ message: 'Build stack must be one of `macos-sequoia`, `macos-tahoe`, or `macos-golden-gate`.',
84
112
  })
85
113
  .optional()
86
114
  .describe('The build stack to use for the build process.'),
@@ -105,14 +133,15 @@ export default defineCommand({
105
133
  }), { y: 'yes' }),
106
134
  action: withAuth(async (options) => {
107
135
  let { appId, platform, type, gitRef, environment, certificate, configuration, json, stack, path: sourcePath, url, } = options;
136
+ const channels = parseListOption(options.channel);
108
137
  // Validate that detached flag cannot be used with artifact flags
109
- if (options.detached && (options.apk || options.aab || options.ipa || options.zip)) {
110
- consola.error('The --detached flag cannot be used with --apk, --aab, --ipa, or --zip flags.');
138
+ if (options.detached && (options.apk || options.aab || options.ipa || options.app || options.zip)) {
139
+ consola.error('The --detached flag cannot be used with --apk, --aab, --ipa, --app, or --zip flags.');
111
140
  process.exit(1);
112
141
  }
113
- // Validate that detached flag cannot be used with channel or destination
114
- if (options.detached && (options.channel || options.destination)) {
115
- consola.error('The --detached flag cannot be used with --channel or --destination flags.');
142
+ // Validate that detached flag cannot be used with destination
143
+ if (options.detached && options.destination) {
144
+ consola.error('The --detached flag cannot be used with the --destination flag.');
116
145
  process.exit(1);
117
146
  }
118
147
  // Validate that detached flag cannot be used with failure summary
@@ -131,10 +160,16 @@ export default defineCommand({
131
160
  process.exit(1);
132
161
  }
133
162
  // Validate that channel and destination cannot be used together
134
- if (options.channel && options.destination) {
163
+ if (channels?.length && options.destination) {
135
164
  consola.error('The --channel and --destination flags cannot be used together.');
136
165
  process.exit(1);
137
166
  }
167
+ // Validate the release notes before the build starts since they are only used for the deployment afterwards
168
+ const releaseNotes = await parseReleaseNotes(options);
169
+ if (releaseNotes && !options.destination) {
170
+ consola.error('The --release-notes, --release-notes-file, and --release-notes-locale flags require --destination.');
171
+ process.exit(1);
172
+ }
138
173
  // Validate that path, url, and gitRef cannot be used together
139
174
  if (sourcePath && gitRef) {
140
175
  consola.error('The --path and --git-ref flags cannot be used together.');
@@ -244,7 +279,7 @@ export default defineCommand({
244
279
  process.exit(1);
245
280
  }
246
281
  // Validate that channel is only used with web platform
247
- if (options.channel && platform !== 'web') {
282
+ if (channels?.length && platform !== 'web') {
248
283
  consola.error('The --channel flag can only be used with the web platform.');
249
284
  process.exit(1);
250
285
  }
@@ -377,6 +412,7 @@ export default defineCommand({
377
412
  adHocEnvironmentVariables,
378
413
  appBuildSourceId,
379
414
  appCertificateName: certificate,
415
+ appChannelNames: channels,
380
416
  appConfigurationName: configuration,
381
417
  appEnvironmentName: environment,
382
418
  appId,
@@ -389,6 +425,9 @@ export default defineCommand({
389
425
  consola.info(`Build Number: ${response.numberAsString}`);
390
426
  consola.info(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${response.id}`);
391
427
  consola.success('Build created successfully.');
428
+ if (channels?.length) {
429
+ consola.info(`The build will be deployed to the following ${channels.length === 1 ? 'channel' : 'channels'} once it succeeds: ${channels.join(', ')}.`);
430
+ }
392
431
  // Wait for build job to complete by default, unless --detached flag is set
393
432
  const shouldWait = !options.detached;
394
433
  if (shouldWait) {
@@ -411,41 +450,18 @@ export default defineCommand({
411
450
  consola.success('Build completed successfully.');
412
451
  console.log();
413
452
  // Download artifacts if flags are set
414
- if (options.apk && platform === 'android') {
415
- await handleArtifactDownload({
416
- appId,
417
- buildId: response.id,
418
- buildArtifacts: appBuild.appBuildArtifacts,
419
- artifactType: 'apk',
420
- filePath: typeof options.apk === 'string' ? options.apk : undefined,
421
- });
422
- }
423
- if (options.aab && platform === 'android') {
424
- await handleArtifactDownload({
425
- appId,
426
- buildId: response.id,
427
- buildArtifacts: appBuild.appBuildArtifacts,
428
- artifactType: 'aab',
429
- filePath: typeof options.aab === 'string' ? options.aab : undefined,
430
- });
431
- }
432
- if (options.ipa && platform === 'ios') {
433
- await handleArtifactDownload({
434
- appId,
435
- buildId: response.id,
436
- buildArtifacts: appBuild.appBuildArtifacts,
437
- artifactType: 'ipa',
438
- filePath: typeof options.ipa === 'string' ? options.ipa : undefined,
439
- });
440
- }
441
- if (options.zip && platform === 'web') {
442
- await handleArtifactDownload({
443
- appId,
444
- buildId: response.id,
445
- buildArtifacts: appBuild.appBuildArtifacts,
446
- artifactType: 'zip',
447
- filePath: typeof options.zip === 'string' ? options.zip : undefined,
448
- });
453
+ for (const artifactType of APP_BUILD_ARTIFACT_TYPES_BY_PLATFORM[platform]) {
454
+ const option = options[artifactType];
455
+ if (option) {
456
+ await handleAppBuildArtifactDownload({
457
+ appId,
458
+ buildId: response.id,
459
+ buildArtifacts: appBuild.appBuildArtifacts,
460
+ artifactType,
461
+ formFactor: options.formFactor,
462
+ filePath: typeof option === 'string' ? option : undefined,
463
+ });
464
+ }
449
465
  }
450
466
  // Create a public share link if requested
451
467
  let appBuildShare;
@@ -471,9 +487,16 @@ export default defineCommand({
471
487
  consola.info(`Share URL: ${shareUrls.webUrl}`);
472
488
  }
473
489
  }
474
- // Create deployment if channel or destination is set
475
- if (options.channel || options.destination) {
476
- await (await import('../../../commands/apps/deployments/create.js').then((mod) => mod.default)).action({ appId, buildId: response.id, channel: options.channel, destination: options.destination }, undefined);
490
+ // Create deployment if destination is set
491
+ if (options.destination) {
492
+ await (await import('../../../commands/apps/deployments/create.js').then((mod) => mod.default)).action({
493
+ appId,
494
+ buildId: response.id,
495
+ destination: options.destination,
496
+ releaseNotes: options.releaseNotes,
497
+ releaseNotesFile: options.releaseNotesFile,
498
+ releaseNotesLocale: options.releaseNotesLocale,
499
+ }, undefined);
477
500
  }
478
501
  // Output JSON if json flag is set
479
502
  if (json) {
@@ -500,45 +523,3 @@ export default defineCommand({
500
523
  }
501
524
  }),
502
525
  });
503
- /**
504
- * Download a build artifact (APK, AAB, IPA, or ZIP).
505
- */
506
- const handleArtifactDownload = async (options) => {
507
- const { appId, buildId, buildArtifacts, artifactType, filePath } = options;
508
- try {
509
- const artifactTypeUpper = artifactType.toUpperCase();
510
- consola.start(`Downloading ${artifactTypeUpper}...`);
511
- // Find the artifact
512
- const artifact = buildArtifacts?.find((artifact) => artifact.type === artifactType);
513
- if (!artifact) {
514
- consola.warn(`No ${artifactTypeUpper} artifact found for this build.`);
515
- return;
516
- }
517
- if (artifact.status !== 'ready') {
518
- consola.warn(`${artifactTypeUpper} artifact is not ready (status: ${artifact.status}).`);
519
- return;
520
- }
521
- // Download the artifact
522
- const artifactData = await appBuildsService.downloadArtifact({
523
- appId,
524
- appBuildId: buildId,
525
- artifactId: artifact.id,
526
- });
527
- // Determine the file path
528
- let outputPath;
529
- if (filePath) {
530
- // Use provided path (can be relative or absolute)
531
- outputPath = path.resolve(filePath);
532
- }
533
- else {
534
- // Default to current working directory with build ID as filename
535
- outputPath = path.resolve(`${buildId}.${artifactType}`);
536
- }
537
- // Save the file
538
- await fs.writeFile(outputPath, Buffer.from(artifactData));
539
- consola.success(`${artifactTypeUpper} downloaded successfully: ${outputPath}`);
540
- }
541
- catch (error) {
542
- consola.error(`Failed to download ${artifactType.toUpperCase()}:`, error);
543
- }
544
- };
@@ -2,7 +2,10 @@ import { DEFAULT_API_BASE_URL, DEFAULT_CONSOLE_BASE_URL } from '../../../config/
2
2
  import authorizationService from '../../../services/authorization-service.js';
3
3
  import userConfig from '../../../utils/user-config.js';
4
4
  import consola from 'consola';
5
+ import fs from 'fs/promises';
5
6
  import nock from 'nock';
7
+ import os from 'os';
8
+ import path from 'path';
6
9
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
10
  import createCommand from './create.js';
8
11
  // Mock dependencies
@@ -77,6 +80,40 @@ describe('apps-builds-create', () => {
77
80
  appBuildShare: { id: shareId, qrCodeUrl, webUrl, expiresAt: null },
78
81
  }, null, 2));
79
82
  });
83
+ it('should download the app artifact of an iOS simulator build with --app', async () => {
84
+ const outputDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'capawesome-cli-create-'));
85
+ const outputPath = path.join(outputDirectory, 'simulator.app.zip');
86
+ const artifactId = 'artifact-1';
87
+ const artifactContent = 'zipped app bundle';
88
+ const options = { appId, platform: 'ios', type: 'simulator', gitRef: 'main', app: outputPath };
89
+ const buildScope = nock(DEFAULT_API_BASE_URL)
90
+ .post(`/v1/apps/${appId}/builds`, (body) => body.platform === 'ios' && body.type === 'simulator')
91
+ .reply(201, { id: buildId, jobId: 'job-1', numberAsString: '42' });
92
+ const findScope = nock(DEFAULT_API_BASE_URL)
93
+ .get(`/v1/apps/${appId}/builds/${buildId}`)
94
+ .query({ relations: 'appBuildArtifacts' })
95
+ .reply(200, {
96
+ id: buildId,
97
+ appBuildArtifacts: [
98
+ { id: 'artifact-0', status: 'ready', type: 'xcarchive' },
99
+ { formFactor: 'mobile', id: artifactId, status: 'ready', type: 'app' },
100
+ ],
101
+ });
102
+ const downloadScope = nock(DEFAULT_API_BASE_URL)
103
+ .get(`/v1/apps/${appId}/builds/${buildId}/artifacts/${artifactId}/download`)
104
+ .reply(200, artifactContent);
105
+ try {
106
+ await createCommand.action(options, undefined);
107
+ expect(buildScope.isDone()).toBe(true);
108
+ expect(findScope.isDone()).toBe(true);
109
+ expect(downloadScope.isDone()).toBe(true);
110
+ expect(await fs.readFile(outputPath, 'utf-8')).toBe(artifactContent);
111
+ expect(mockConsola.success).toHaveBeenCalledWith(`APP downloaded successfully: ${outputPath}`);
112
+ }
113
+ finally {
114
+ await fs.rm(outputDirectory, { force: true, recursive: true });
115
+ }
116
+ });
80
117
  it('should reject --share combined with --detached', async () => {
81
118
  const options = { appId, platform: 'web', gitRef: 'main', share: true, detached: true };
82
119
  await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
@@ -107,6 +144,91 @@ describe('apps-builds-create', () => {
107
144
  expect(buildScope.isDone()).toBe(true);
108
145
  expect(mockConsola.error).not.toHaveBeenCalled();
109
146
  });
147
+ it('should send the channels as appChannelNames', async () => {
148
+ const options = { appId, platform: 'web', gitRef: 'main', channel: ['beta', 'alpha'], detached: true };
149
+ const buildScope = nock(DEFAULT_API_BASE_URL)
150
+ .post(`/v1/apps/${appId}/builds`, (body) => JSON.stringify(body.appChannelNames) === JSON.stringify(['beta', 'alpha']))
151
+ .matchHeader('Authorization', `Bearer ${testToken}`)
152
+ .reply(201, { id: buildId, jobId: 'job-1', numberAsString: '42' });
153
+ await createCommand.action(options, undefined);
154
+ expect(buildScope.isDone()).toBe(true);
155
+ expect(mockConsola.error).not.toHaveBeenCalled();
156
+ });
157
+ it('should split comma-separated channels into separate appChannelNames', async () => {
158
+ const options = { appId, platform: 'web', gitRef: 'main', channel: ['beta, alpha'], detached: true };
159
+ const buildScope = nock(DEFAULT_API_BASE_URL)
160
+ .post(`/v1/apps/${appId}/builds`, (body) => JSON.stringify(body.appChannelNames) === JSON.stringify(['beta', 'alpha']))
161
+ .matchHeader('Authorization', `Bearer ${testToken}`)
162
+ .reply(201, { id: buildId, jobId: 'job-1', numberAsString: '42' });
163
+ await createCommand.action(options, undefined);
164
+ expect(buildScope.isDone()).toBe(true);
165
+ expect(mockConsola.error).not.toHaveBeenCalled();
166
+ });
167
+ it('should accept --channel combined with --detached', async () => {
168
+ const options = { appId, platform: 'web', gitRef: 'main', channel: ['beta'], detached: true };
169
+ const buildScope = nock(DEFAULT_API_BASE_URL)
170
+ .post(`/v1/apps/${appId}/builds`)
171
+ .matchHeader('Authorization', `Bearer ${testToken}`)
172
+ .reply(201, { id: buildId, jobId: 'job-1', numberAsString: '42' });
173
+ await createCommand.action(options, undefined);
174
+ expect(buildScope.isDone()).toBe(true);
175
+ expect(mockConsola.error).not.toHaveBeenCalled();
176
+ });
177
+ it('should reject --detached combined with --destination', async () => {
178
+ const options = { appId, platform: 'ios', gitRef: 'main', destination: 'testflight', detached: true };
179
+ await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
180
+ expect(mockConsola.error).toHaveBeenCalledWith('The --detached flag cannot be used with the --destination flag.');
181
+ });
182
+ it('should reject release notes without --destination', async () => {
183
+ const options = { appId, platform: 'android', gitRef: 'main', releaseNotes: 'Bug fixes' };
184
+ await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
185
+ expect(mockConsola.error).toHaveBeenCalledWith('The --release-notes, --release-notes-file, and --release-notes-locale flags require --destination.');
186
+ });
187
+ it('should send the release notes with the deployment to the destination', async () => {
188
+ const options = {
189
+ appId,
190
+ platform: 'android',
191
+ gitRef: 'main',
192
+ destination: 'Google Play',
193
+ releaseNotes: 'Bug fixes',
194
+ releaseNotesLocale: ['de-DE=Fehlerbehebungen'],
195
+ };
196
+ const buildScope = nock(DEFAULT_API_BASE_URL)
197
+ .post(`/v1/apps/${appId}/builds`)
198
+ .reply(201, { id: buildId, jobId: 'job-1', numberAsString: '42' });
199
+ const findScope = nock(DEFAULT_API_BASE_URL)
200
+ .get(`/v1/apps/${appId}/builds/${buildId}`)
201
+ .query({ relations: 'appBuildArtifacts' })
202
+ .reply(200, { id: buildId, appBuildArtifacts: [] });
203
+ const deploymentBuildScope = nock(DEFAULT_API_BASE_URL)
204
+ .get(`/v1/apps/${appId}/builds/${buildId}`)
205
+ .reply(200, { id: buildId, platform: 'android' });
206
+ const deploymentScope = nock(DEFAULT_API_BASE_URL)
207
+ .post(`/v1/apps/${appId}/deployments`, {
208
+ appId,
209
+ appBuildId: buildId,
210
+ appDestinationName: 'Google Play',
211
+ releaseNotes: { default: 'Bug fixes', 'de-DE': 'Fehlerbehebungen' },
212
+ })
213
+ .matchHeader('Authorization', `Bearer ${testToken}`)
214
+ .reply(201, { id: 'deployment-1', jobId: 'job-2' });
215
+ await createCommand.action(options, undefined);
216
+ expect(buildScope.isDone()).toBe(true);
217
+ expect(findScope.isDone()).toBe(true);
218
+ expect(deploymentBuildScope.isDone()).toBe(true);
219
+ expect(deploymentScope.isDone()).toBe(true);
220
+ });
221
+ it('should parse a single channel into an array', () => {
222
+ const schema = createCommand.options?.schema;
223
+ const result = schema?.safeParse({
224
+ appId: validAppId,
225
+ platform: 'web',
226
+ gitRef: 'main',
227
+ channel: ['beta'],
228
+ });
229
+ expect(result?.success).toBe(true);
230
+ expect(result?.data?.channel).toEqual(['beta']);
231
+ });
110
232
  it('should reject a non-positive shareExpiresInDays value', () => {
111
233
  const schema = createCommand.options?.schema;
112
234
  for (const shareExpiresInDays of [0, -1]) {