@capawesome/cli 4.22.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,20 @@
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
+
5
19
  ## [4.22.0](https://github.com/capawesome-team/cli/compare/v4.21.0...v4.22.0) (2026-09-24)
6
20
 
7
21
 
@@ -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.',
@@ -10,11 +10,13 @@ import { getAppBuildShareUrls } from '../../../utils/app-build-shares.js';
10
10
  import { parseKeyValuePairs } from '../../../utils/app-environments.js';
11
11
  import { withAuth } from '../../../utils/auth.js';
12
12
  import { createBufferFromPath } from '../../../utils/buffer.js';
13
+ import { parseListOption } from '../../../utils/cli-options.js';
13
14
  import { isInteractive } from '../../../utils/environment.js';
14
15
  import { offerJobFailureSummary } from '../../../utils/job-failure-summary.js';
15
16
  import { isDirectory, isReadable } from '../../../utils/file.js';
16
17
  import { waitForJobCompletion } from '../../../utils/job.js';
17
18
  import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
19
+ import { parseReleaseNotes } from '../../../utils/release-notes.js';
18
20
  import zip from '../../../utils/zip.js';
19
21
  import consola from 'consola';
20
22
  import fs from 'fs/promises';
@@ -45,7 +47,10 @@ export default defineCommand({
45
47
  .optional()
46
48
  .describe('App ID to create the build for.'),
47
49
  certificate: z.string().optional().describe('The name of the certificate to use for the build.'),
48
- 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.'),
49
54
  configuration: z.string().optional().describe('The name of the native configuration (Android/iOS only).'),
50
55
  destination: z.string().optional().describe('The name of the destination to deploy to (Android/iOS only).'),
51
56
  detached: z
@@ -76,6 +81,18 @@ export default defineCommand({
76
81
  })
77
82
  .optional()
78
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.'),
79
96
  share: z.boolean().optional().describe('Create a public share link for the build after it succeeds.'),
80
97
  shareDescription: z
81
98
  .string()
@@ -116,14 +133,15 @@ export default defineCommand({
116
133
  }), { y: 'yes' }),
117
134
  action: withAuth(async (options) => {
118
135
  let { appId, platform, type, gitRef, environment, certificate, configuration, json, stack, path: sourcePath, url, } = options;
136
+ const channels = parseListOption(options.channel);
119
137
  // Validate that detached flag cannot be used with artifact flags
120
138
  if (options.detached && (options.apk || options.aab || options.ipa || options.app || options.zip)) {
121
139
  consola.error('The --detached flag cannot be used with --apk, --aab, --ipa, --app, or --zip flags.');
122
140
  process.exit(1);
123
141
  }
124
- // Validate that detached flag cannot be used with channel or destination
125
- if (options.detached && (options.channel || options.destination)) {
126
- 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.');
127
145
  process.exit(1);
128
146
  }
129
147
  // Validate that detached flag cannot be used with failure summary
@@ -142,10 +160,16 @@ export default defineCommand({
142
160
  process.exit(1);
143
161
  }
144
162
  // Validate that channel and destination cannot be used together
145
- if (options.channel && options.destination) {
163
+ if (channels?.length && options.destination) {
146
164
  consola.error('The --channel and --destination flags cannot be used together.');
147
165
  process.exit(1);
148
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
+ }
149
173
  // Validate that path, url, and gitRef cannot be used together
150
174
  if (sourcePath && gitRef) {
151
175
  consola.error('The --path and --git-ref flags cannot be used together.');
@@ -255,7 +279,7 @@ export default defineCommand({
255
279
  process.exit(1);
256
280
  }
257
281
  // Validate that channel is only used with web platform
258
- if (options.channel && platform !== 'web') {
282
+ if (channels?.length && platform !== 'web') {
259
283
  consola.error('The --channel flag can only be used with the web platform.');
260
284
  process.exit(1);
261
285
  }
@@ -388,6 +412,7 @@ export default defineCommand({
388
412
  adHocEnvironmentVariables,
389
413
  appBuildSourceId,
390
414
  appCertificateName: certificate,
415
+ appChannelNames: channels,
391
416
  appConfigurationName: configuration,
392
417
  appEnvironmentName: environment,
393
418
  appId,
@@ -400,6 +425,9 @@ export default defineCommand({
400
425
  consola.info(`Build Number: ${response.numberAsString}`);
401
426
  consola.info(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${response.id}`);
402
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
+ }
403
431
  // Wait for build job to complete by default, unless --detached flag is set
404
432
  const shouldWait = !options.detached;
405
433
  if (shouldWait) {
@@ -459,9 +487,16 @@ export default defineCommand({
459
487
  consola.info(`Share URL: ${shareUrls.webUrl}`);
460
488
  }
461
489
  }
462
- // Create deployment if channel or destination is set
463
- if (options.channel || options.destination) {
464
- 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);
465
500
  }
466
501
  // Output JSON if json flag is set
467
502
  if (json) {
@@ -144,6 +144,91 @@ describe('apps-builds-create', () => {
144
144
  expect(buildScope.isDone()).toBe(true);
145
145
  expect(mockConsola.error).not.toHaveBeenCalled();
146
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
+ });
147
232
  it('should reject a non-positive shareExpiresInDays value', () => {
148
233
  const schema = createCommand.options?.schema;
149
234
  for (const shareExpiresInDays of [0, -1]) {
@@ -7,6 +7,7 @@ import { isInteractive } from '../../../utils/environment.js';
7
7
  import { offerJobFailureSummary } from '../../../utils/job-failure-summary.js';
8
8
  import { waitForJobCompletion } from '../../../utils/job.js';
9
9
  import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
10
+ import { parseReleaseNotes } from '../../../utils/release-notes.js';
10
11
  import consola from 'consola';
11
12
  import { z } from 'zod';
12
13
  import { defineCommand, defineOptions } from 'zodline';
@@ -37,9 +38,19 @@ export default defineCommand({
37
38
  .optional()
38
39
  .describe('Request an AI-powered failure summary (Capawesome Cloud Assist) if the deployment fails.'),
39
40
  json: z.boolean().optional().describe('Output in JSON format.'),
41
+ releaseNotes: z.string().optional().describe('Release notes for the deployment (default text).'),
42
+ releaseNotesFile: z
43
+ .string()
44
+ .optional()
45
+ .describe('Path to a JSON file with release notes by locale, e.g. `{"default": "…", "de-DE": "…"}`.'),
46
+ releaseNotesLocale: z
47
+ .array(z.string())
48
+ .optional()
49
+ .describe('Translated release notes as `<locale>=<text>`, e.g. `de-DE=Fehlerbehebungen`. Can be specified multiple times.'),
40
50
  })),
41
51
  action: withAuth(async (options) => {
42
52
  let { appId, buildId, buildNumber, channel, destination, json } = options;
53
+ const releaseNotes = await parseReleaseNotes(options);
43
54
  // Prompt for app ID if not provided
44
55
  if (!appId) {
45
56
  if (!isInteractive()) {
@@ -157,6 +168,7 @@ export default defineCommand({
157
168
  appBuildId: buildId,
158
169
  appDestinationName: build.platform === 'web' ? undefined : destination,
159
170
  appChannelName: build.platform === 'web' ? channel : undefined,
171
+ releaseNotes,
160
172
  });
161
173
  consola.info(`Deployment ID: ${response.id}`);
162
174
  consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${response.id}`);
@@ -0,0 +1,70 @@
1
+ import { DEFAULT_API_BASE_URL } from '../../../config/consts.js';
2
+ import authorizationService from '../../../services/authorization-service.js';
3
+ import userConfig from '../../../utils/user-config.js';
4
+ import consola from 'consola';
5
+ import nock from 'nock';
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import createCommand from './create.js';
8
+ // Mock dependencies
9
+ vi.mock('@/utils/user-config.js');
10
+ vi.mock('@/utils/prompt.js');
11
+ vi.mock('@/services/authorization-service.js');
12
+ vi.mock('@/utils/job.js');
13
+ vi.mock('consola');
14
+ vi.mock('@/utils/environment.js', () => ({
15
+ isInteractive: () => false,
16
+ }));
17
+ describe('apps-deployments-create', () => {
18
+ const mockUserConfig = vi.mocked(userConfig);
19
+ const mockAuthorizationService = vi.mocked(authorizationService);
20
+ const mockConsola = vi.mocked(consola);
21
+ const testToken = 'test-token';
22
+ const appId = '00000000-0000-0000-0000-000000000001';
23
+ const buildId = '00000000-0000-0000-0000-000000000002';
24
+ const deploymentId = 'deployment-1';
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ mockUserConfig.read.mockReturnValue({ token: testToken });
28
+ mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
29
+ mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(testToken);
30
+ vi.spyOn(process, 'exit').mockImplementation((code) => {
31
+ throw new Error(`Process exited with code ${code}`);
32
+ });
33
+ });
34
+ afterEach(() => {
35
+ nock.cleanAll();
36
+ vi.restoreAllMocks();
37
+ });
38
+ it('should send the release notes with the deployment', async () => {
39
+ const options = {
40
+ appId,
41
+ buildId,
42
+ destination: 'Google Play',
43
+ detached: true,
44
+ releaseNotes: 'Bug fixes',
45
+ releaseNotesLocale: ['de-DE=Fehlerbehebungen'],
46
+ };
47
+ const buildScope = nock(DEFAULT_API_BASE_URL)
48
+ .get(`/v1/apps/${appId}/builds/${buildId}`)
49
+ .matchHeader('Authorization', `Bearer ${testToken}`)
50
+ .reply(200, { id: buildId, platform: 'android' });
51
+ const deploymentScope = nock(DEFAULT_API_BASE_URL)
52
+ .post(`/v1/apps/${appId}/deployments`, {
53
+ appId,
54
+ appBuildId: buildId,
55
+ appDestinationName: 'Google Play',
56
+ releaseNotes: { default: 'Bug fixes', 'de-DE': 'Fehlerbehebungen' },
57
+ })
58
+ .matchHeader('Authorization', `Bearer ${testToken}`)
59
+ .reply(201, { id: deploymentId, jobId: 'job-1' });
60
+ await createCommand.action(options, undefined);
61
+ expect(buildScope.isDone()).toBe(true);
62
+ expect(deploymentScope.isDone()).toBe(true);
63
+ expect(mockConsola.success).toHaveBeenCalledWith('Deployment created successfully.');
64
+ });
65
+ it('should reject translated release notes without a default text', async () => {
66
+ const options = { appId, buildId, destination: 'Google Play', releaseNotesLocale: ['de-DE=Fehlerbehebungen'] };
67
+ await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
68
+ expect(mockConsola.error).toHaveBeenCalledWith('Release notes require a default text (--release-notes).');
69
+ });
70
+ });