@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.
@@ -1,12 +1,17 @@
1
1
  import appBuildsService from '../../../services/app-builds.js';
2
+ import { APP_BUILD_ARTIFACT_FORM_FACTORS, APP_BUILD_ARTIFACT_LABELS, APP_BUILD_ARTIFACT_TYPES_BY_PLATFORM, handleAppBuildArtifactDownload, } from '../../../utils/app-build-artifacts.js';
2
3
  import { withAuth } from '../../../utils/auth.js';
4
+ import { isInteractive } from '../../../utils/environment.js';
3
5
  import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
4
6
  import consola from 'consola';
5
- import fs from 'fs/promises';
6
- import path from 'path';
7
- import { isInteractive } from '../../../utils/environment.js';
8
7
  import { z } from 'zod';
9
8
  import { defineCommand, defineOptions } from 'zodline';
9
+ const ARTIFACT_TYPES = ['apk', 'aab', 'ipa', 'app', 'zip'];
10
+ const PLATFORM_LABELS = {
11
+ android: 'Android',
12
+ ios: 'iOS',
13
+ web: 'Web',
14
+ };
10
15
  export default defineCommand({
11
16
  description: 'Download an app build.',
12
17
  options: defineOptions(z.object({
@@ -22,22 +27,32 @@ export default defineCommand({
22
27
  })
23
28
  .optional()
24
29
  .describe('Build ID to download.'),
30
+ formFactor: z
31
+ .enum(APP_BUILD_ARTIFACT_FORM_FACTORS, {
32
+ message: 'Invalid form factor. Must be one of `mobile`, `watch`, `tv`, or `automotive`.',
33
+ })
34
+ .optional()
35
+ .describe('The form factor of the Android artifact to download. Supported values are `mobile`, `watch`, `tv`, and `automotive`. Without it, the first artifact in the order `mobile`, `watch`, `tv`, `automotive` is downloaded.'),
25
36
  apk: z
26
37
  .union([z.boolean(), z.string()])
27
38
  .optional()
28
- .describe('Download the APK artifact. Optionally provide a file path.'),
39
+ .describe('Download the APK artifact (Android only). Optionally provide a file path.'),
29
40
  aab: z
30
41
  .union([z.boolean(), z.string()])
31
42
  .optional()
32
- .describe('Download the AAB artifact. Optionally provide a file path.'),
43
+ .describe('Download the AAB artifact (Android only). Optionally provide a file path.'),
33
44
  ipa: z
34
45
  .union([z.boolean(), z.string()])
35
46
  .optional()
36
- .describe('Download the IPA artifact. Optionally provide a file path.'),
47
+ .describe('Download the IPA artifact (iOS only). Optionally provide a file path.'),
48
+ app: z
49
+ .union([z.boolean(), z.string()])
50
+ .optional()
51
+ .describe('Download the APP artifact, a zipped `.app` bundle (iOS simulator builds only). Optionally provide a file path.'),
37
52
  zip: z
38
53
  .union([z.boolean(), z.string()])
39
54
  .optional()
40
- .describe('Download the ZIP artifact. Optionally provide a file path.'),
55
+ .describe('Download the ZIP artifact (Web only). Optionally provide a file path.'),
41
56
  })),
42
57
  action: withAuth(async (options) => {
43
58
  let { appId, buildId } = options;
@@ -74,156 +89,54 @@ export default defineCommand({
74
89
  process.exit(1);
75
90
  }
76
91
  }
77
- // Fetch the build details to get the job ID
78
92
  const build = await appBuildsService.findOne({ appId, appBuildId: buildId, relations: 'appBuildArtifacts,job' });
79
93
  if (build.job?.status !== 'succeeded') {
80
94
  consola.error('The build has not succeeded yet. Cannot download artifacts for incomplete builds.');
81
95
  process.exit(1);
82
96
  }
83
- // Validate platform-specific artifact flags
84
- if (build.platform === 'android' && options.ipa) {
85
- consola.error('Cannot download IPA artifact for an Android build.');
86
- process.exit(1);
87
- }
88
- if (build.platform === 'ios' && (options.apk || options.aab)) {
89
- consola.error('Cannot download APK or AAB artifacts for an iOS build.');
90
- process.exit(1);
91
- }
92
- if (build.platform === 'web' && (options.apk || options.aab || options.ipa)) {
93
- consola.error('Cannot download APK, AAB, or IPA artifacts for a Web build.');
97
+ // Reject artifact flags that do not exist for the build's platform
98
+ const supportedArtifactTypes = APP_BUILD_ARTIFACT_TYPES_BY_PLATFORM[build.platform];
99
+ const requestedArtifactTypes = ARTIFACT_TYPES.filter((artifactType) => options[artifactType]);
100
+ const unsupportedArtifactType = requestedArtifactTypes.find((artifactType) => !supportedArtifactTypes.includes(artifactType));
101
+ if (unsupportedArtifactType) {
102
+ consola.error(`The ${APP_BUILD_ARTIFACT_LABELS[unsupportedArtifactType]} artifact is not available for ${PLATFORM_LABELS[build.platform]} builds.`);
94
103
  process.exit(1);
95
104
  }
96
- // Determine which artifacts to download
97
- let downloadApk = options.apk;
98
- let downloadAab = options.aab;
99
- let downloadIpa = options.ipa;
100
- let downloadZip = options.zip;
101
105
  // Prompt for artifact types if none were provided
102
- if (!downloadApk && !downloadAab && !downloadIpa && !downloadZip) {
106
+ let artifactTypesToDownload = requestedArtifactTypes;
107
+ if (artifactTypesToDownload.length === 0) {
103
108
  if (!isInteractive()) {
104
- consola.error('You must specify at least one artifact type (--apk, --aab, --ipa, or --zip) when running in non-interactive environment.');
109
+ consola.error('You must specify at least one artifact type (--apk, --aab, --ipa, --app, or --zip) when running in non-interactive environment.');
105
110
  process.exit(1);
106
111
  }
107
- // Get available artifact types from the build
108
- const availableArtifacts = build.appBuildArtifacts?.filter((artifact) => artifact.status === 'ready').map((artifact) => artifact.type) ||
109
- [];
110
- if (availableArtifacts.length === 0) {
112
+ const availableArtifactTypes = supportedArtifactTypes.filter((artifactType) => build.appBuildArtifacts?.some((artifact) => artifact.type === artifactType && artifact.status === 'ready'));
113
+ if (availableArtifactTypes.length === 0) {
111
114
  consola.error('No artifacts available for download.');
112
115
  process.exit(1);
113
116
  }
114
- // Create options based on available artifacts and platform
115
- const artifactOptions = [];
116
- if (build.platform === 'android') {
117
- if (availableArtifacts.includes('apk')) {
118
- artifactOptions.push({ label: 'APK', value: 'apk' });
119
- }
120
- if (availableArtifacts.includes('aab')) {
121
- artifactOptions.push({ label: 'AAB', value: 'aab' });
122
- }
123
- }
124
- else if (build.platform === 'ios') {
125
- if (availableArtifacts.includes('ipa')) {
126
- artifactOptions.push({ label: 'IPA', value: 'ipa' });
127
- }
128
- }
129
- else if (build.platform === 'web') {
130
- if (availableArtifacts.includes('zip')) {
131
- artifactOptions.push({ label: 'ZIP', value: 'zip' });
132
- }
133
- }
134
117
  // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
135
- const selectedArtifacts = await prompt('Which artifact type(s) do you want to download:', {
118
+ artifactTypesToDownload = await prompt('Which artifact type(s) do you want to download:', {
136
119
  type: 'multiselect',
137
- options: artifactOptions,
120
+ options: availableArtifactTypes.map((artifactType) => ({
121
+ label: APP_BUILD_ARTIFACT_LABELS[artifactType],
122
+ value: artifactType,
123
+ })),
138
124
  });
139
- if (!selectedArtifacts || selectedArtifacts.length === 0) {
125
+ if (!artifactTypesToDownload || artifactTypesToDownload.length === 0) {
140
126
  consola.error('You must select at least one artifact type to download.');
141
127
  process.exit(1);
142
128
  }
143
- // Set flags based on selection
144
- downloadApk = selectedArtifacts.includes('apk');
145
- downloadAab = selectedArtifacts.includes('aab');
146
- downloadIpa = selectedArtifacts.includes('ipa');
147
- downloadZip = selectedArtifacts.includes('zip');
148
- }
149
- // Download artifacts if flags are set
150
- if (downloadApk) {
151
- await handleArtifactDownload({
152
- appId,
153
- buildId: buildId,
154
- buildArtifacts: build.appBuildArtifacts,
155
- artifactType: 'apk',
156
- filePath: typeof options.apk === 'string' ? options.apk : undefined,
157
- });
158
- }
159
- if (downloadAab) {
160
- await handleArtifactDownload({
161
- appId,
162
- buildId: buildId,
163
- buildArtifacts: build.appBuildArtifacts,
164
- artifactType: 'aab',
165
- filePath: typeof options.aab === 'string' ? options.aab : undefined,
166
- });
167
- }
168
- if (downloadIpa) {
169
- await handleArtifactDownload({
170
- appId,
171
- buildId: buildId,
172
- buildArtifacts: build.appBuildArtifacts,
173
- artifactType: 'ipa',
174
- filePath: typeof options.ipa === 'string' ? options.ipa : undefined,
175
- });
176
129
  }
177
- if (downloadZip) {
178
- await handleArtifactDownload({
130
+ for (const artifactType of artifactTypesToDownload) {
131
+ const option = options[artifactType];
132
+ await handleAppBuildArtifactDownload({
179
133
  appId,
180
- buildId: buildId,
134
+ buildId,
181
135
  buildArtifacts: build.appBuildArtifacts,
182
- artifactType: 'zip',
183
- filePath: typeof options.zip === 'string' ? options.zip : undefined,
136
+ artifactType,
137
+ formFactor: options.formFactor,
138
+ filePath: typeof option === 'string' ? option : undefined,
184
139
  });
185
140
  }
186
141
  }),
187
142
  });
188
- /**
189
- * Download a build artifact (APK, AAB, IPA, or ZIP).
190
- */
191
- const handleArtifactDownload = async (options) => {
192
- const { appId, buildId, buildArtifacts, artifactType, filePath } = options;
193
- try {
194
- const artifactTypeUpper = artifactType.toUpperCase();
195
- consola.start(`Downloading ${artifactTypeUpper}...`);
196
- // Find the artifact
197
- const artifact = buildArtifacts?.find((artifact) => artifact.type === artifactType);
198
- if (!artifact) {
199
- consola.warn(`No ${artifactTypeUpper} artifact found for this build.`);
200
- return;
201
- }
202
- if (artifact.status !== 'ready') {
203
- consola.warn(`${artifactTypeUpper} artifact is not ready (status: ${artifact.status}).`);
204
- return;
205
- }
206
- // Download the artifact
207
- const artifactData = await appBuildsService.downloadArtifact({
208
- appId,
209
- appBuildId: buildId,
210
- artifactId: artifact.id,
211
- });
212
- // Determine the file path
213
- let outputPath;
214
- if (filePath) {
215
- // Use provided path (can be relative or absolute)
216
- outputPath = path.resolve(filePath);
217
- }
218
- else {
219
- // Default to current working directory with build ID as filename
220
- outputPath = path.resolve(`${buildId}.${artifactType}`);
221
- }
222
- // Save the file
223
- await fs.writeFile(outputPath, Buffer.from(artifactData));
224
- consola.success(`${artifactTypeUpper} downloaded successfully: ${outputPath}`);
225
- }
226
- catch (error) {
227
- consola.error(`Failed to download ${artifactType.toUpperCase()}:`, error);
228
- }
229
- };
@@ -0,0 +1,85 @@
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 fs from 'fs/promises';
6
+ import nock from 'nock';
7
+ import os from 'os';
8
+ import path from 'path';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import downloadCommand from './download.js';
11
+ vi.mock('@/utils/user-config.js');
12
+ vi.mock('@/utils/prompt.js');
13
+ vi.mock('@/services/authorization-service.js');
14
+ vi.mock('consola');
15
+ vi.mock('@/utils/environment.js', () => ({
16
+ isInteractive: () => false,
17
+ }));
18
+ describe('apps-builds-download', () => {
19
+ const mockUserConfig = vi.mocked(userConfig);
20
+ const mockAuthorizationService = vi.mocked(authorizationService);
21
+ const mockConsola = vi.mocked(consola);
22
+ const testToken = 'test-token';
23
+ const appId = '00000000-0000-0000-0000-000000000001';
24
+ const buildId = '00000000-0000-0000-0000-000000000002';
25
+ const artifactId = 'artifact-1';
26
+ const artifactContent = 'zipped app bundle';
27
+ let outputDirectory;
28
+ const mockSimulatorBuild = () => nock(DEFAULT_API_BASE_URL)
29
+ .get(`/v1/apps/${appId}/builds/${buildId}`)
30
+ .query({ relations: 'appBuildArtifacts,job' })
31
+ .matchHeader('Authorization', `Bearer ${testToken}`)
32
+ .reply(200, {
33
+ id: buildId,
34
+ platform: 'ios',
35
+ type: 'simulator',
36
+ job: { status: 'succeeded' },
37
+ appBuildArtifacts: [
38
+ { id: 'artifact-0', status: 'ready', type: 'xcarchive' },
39
+ { formFactor: 'mobile', id: artifactId, status: 'ready', type: 'app' },
40
+ ],
41
+ });
42
+ beforeEach(async () => {
43
+ vi.clearAllMocks();
44
+ mockUserConfig.read.mockReturnValue({ token: testToken });
45
+ mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
46
+ mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(testToken);
47
+ vi.spyOn(process, 'exit').mockImplementation((code) => {
48
+ throw new Error(`Process exited with code ${code}`);
49
+ });
50
+ outputDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'capawesome-cli-download-'));
51
+ });
52
+ afterEach(async () => {
53
+ nock.cleanAll();
54
+ vi.restoreAllMocks();
55
+ await fs.rm(outputDirectory, { force: true, recursive: true });
56
+ });
57
+ it('should download the app artifact of an iOS simulator build with --app', async () => {
58
+ const outputPath = path.join(outputDirectory, 'simulator.app.zip');
59
+ const buildScope = mockSimulatorBuild();
60
+ const downloadScope = nock(DEFAULT_API_BASE_URL)
61
+ .get(`/v1/apps/${appId}/builds/${buildId}/artifacts/${artifactId}/download`)
62
+ .matchHeader('Authorization', `Bearer ${testToken}`)
63
+ .reply(200, artifactContent);
64
+ await downloadCommand.action({ appId, buildId, app: outputPath }, undefined);
65
+ expect(buildScope.isDone()).toBe(true);
66
+ expect(downloadScope.isDone()).toBe(true);
67
+ expect(await fs.readFile(outputPath, 'utf-8')).toBe(artifactContent);
68
+ expect(mockConsola.success).toHaveBeenCalledWith(`APP downloaded successfully: ${outputPath}`);
69
+ });
70
+ it('should write the app artifact to `<build-id>.app.zip` by default', async () => {
71
+ vi.spyOn(process, 'cwd').mockReturnValue(outputDirectory);
72
+ mockSimulatorBuild();
73
+ nock(DEFAULT_API_BASE_URL)
74
+ .get(`/v1/apps/${appId}/builds/${buildId}/artifacts/${artifactId}/download`)
75
+ .reply(200, artifactContent);
76
+ await downloadCommand.action({ appId, buildId, app: true }, undefined);
77
+ const outputPath = path.join(outputDirectory, `${buildId}.app.zip`);
78
+ expect(await fs.readFile(outputPath, 'utf-8')).toBe(artifactContent);
79
+ });
80
+ it('should reject --zip for an iOS build', async () => {
81
+ mockSimulatorBuild();
82
+ await expect(downloadCommand.action({ appId, buildId, zip: true }, undefined)).rejects.toThrow('Process exited with code 1');
83
+ expect(mockConsola.error).toHaveBeenCalledWith('The ZIP artifact is not available for iOS builds.');
84
+ });
85
+ });
@@ -1,6 +1,7 @@
1
1
  import appBuildsService from '../../../services/app-builds.js';
2
2
  import { defineCommand, defineOptions } from 'zodline';
3
3
  import { bootAndroidEmulator, findAllAndroidEmulators, installAndroidApp, launchAndroidApp, } from '../../../utils/android-emulator.js';
4
+ import { findAppBuildArtifact } from '../../../utils/app-build-artifacts.js';
4
5
  import { withAuth } from '../../../utils/auth.js';
5
6
  import { isInteractive } from '../../../utils/environment.js';
6
7
  import { bootIosSimulator, findAllIosSimulators, installIosApp, launchIosApp, } from '../../../utils/ios-simulator.js';
@@ -99,7 +100,7 @@ export default defineCommand({
99
100
  process.exit(1);
100
101
  }
101
102
  const artifactType = isAndroid ? 'apk' : 'app';
102
- const artifact = build.appBuildArtifacts?.find((artifact) => artifact.type === artifactType && artifact.status === 'ready');
103
+ const artifact = findAppBuildArtifact(build.appBuildArtifacts?.filter((artifact) => artifact.status === 'ready'), { type: artifactType });
103
104
  if (!artifact) {
104
105
  consola.error(`No ${artifactType.toUpperCase()} artifact is available for this build.`);
105
106
  process.exit(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
+ });