@capawesome/cli 4.17.3 → 4.18.0-dev.9810ff51.1785743282

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 (39) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/commands/apps/automations/create.js +141 -0
  3. package/dist/commands/apps/automations/create.test.js +162 -0
  4. package/dist/commands/apps/automations/delete.js +66 -0
  5. package/dist/commands/apps/automations/delete.test.js +127 -0
  6. package/dist/commands/apps/automations/get.js +62 -0
  7. package/dist/commands/apps/automations/get.test.js +118 -0
  8. package/dist/commands/apps/automations/list.js +46 -0
  9. package/dist/commands/apps/automations/list.test.js +92 -0
  10. package/dist/commands/apps/automations/update.js +118 -0
  11. package/dist/commands/apps/automations/update.test.js +124 -0
  12. package/dist/commands/apps/builds/create.js +31 -1
  13. package/dist/commands/apps/builds/create.test.js +15 -0
  14. package/dist/commands/apps/builds/run.js +265 -0
  15. package/dist/commands/apps/configurations/create.js +51 -0
  16. package/dist/commands/apps/configurations/create.test.js +120 -0
  17. package/dist/commands/apps/configurations/delete.js +61 -0
  18. package/dist/commands/apps/configurations/delete.test.js +112 -0
  19. package/dist/commands/apps/configurations/get.js +65 -0
  20. package/dist/commands/apps/configurations/get.test.js +119 -0
  21. package/dist/commands/apps/configurations/list.js +39 -0
  22. package/dist/commands/apps/configurations/list.test.js +94 -0
  23. package/dist/commands/apps/configurations/update.js +61 -0
  24. package/dist/commands/apps/configurations/update.test.js +122 -0
  25. package/dist/commands/apps/import.js +419 -0
  26. package/dist/commands/apps/import.test.js +308 -0
  27. package/dist/index.js +13 -0
  28. package/dist/services/app-automations.js +64 -0
  29. package/dist/services/app-configurations.js +77 -0
  30. package/dist/types/app-automation.js +1 -0
  31. package/dist/types/app-configuration.js +1 -0
  32. package/dist/types/index.js +1 -0
  33. package/dist/utils/android-emulator.js +170 -0
  34. package/dist/utils/app-import.js +10 -0
  35. package/dist/utils/appflow-export.js +391 -0
  36. package/dist/utils/appflow-export.test.js +277 -0
  37. package/dist/utils/ios-simulator.js +57 -0
  38. package/dist/utils/zip.js +4 -0
  39. package/package.json +1 -1
@@ -0,0 +1,308 @@
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 zip from '../../utils/zip.js';
5
+ import consola from 'consola';
6
+ import fs from 'fs';
7
+ import nock from 'nock';
8
+ import os from 'os';
9
+ import path from 'path';
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import importCommand from './import.js';
12
+ // Mock dependencies
13
+ vi.mock('@/utils/user-config.js');
14
+ vi.mock('@/utils/prompt.js');
15
+ vi.mock('@/services/authorization-service.js');
16
+ vi.mock('consola');
17
+ vi.mock('@/utils/environment.js', () => ({
18
+ isInteractive: () => false,
19
+ }));
20
+ describe('apps-import', () => {
21
+ const mockUserConfig = vi.mocked(userConfig);
22
+ const mockAuthorizationService = vi.mocked(authorizationService);
23
+ const organizationId = 'org-123';
24
+ let fixtureDirectory;
25
+ let exportFile;
26
+ let consoleLogSpy;
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ mockUserConfig.read.mockReturnValue({ token: 'test-token' });
30
+ mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue('test-token');
31
+ mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
32
+ vi.spyOn(process, 'exit').mockImplementation((code) => {
33
+ throw new Error(`Process exited with code ${code}`);
34
+ });
35
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
36
+ vi.spyOn(console, 'table').mockImplementation(() => undefined);
37
+ fixtureDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'apps-import-test-'));
38
+ });
39
+ afterEach(() => {
40
+ nock.cleanAll();
41
+ vi.restoreAllMocks();
42
+ fs.rmSync(fixtureDirectory, { recursive: true, force: true });
43
+ });
44
+ const writeExportFile = async (apps) => {
45
+ const exportDirectory = path.join(fixtureDirectory, 'export');
46
+ for (const app of apps) {
47
+ for (const [fileName, content] of Object.entries(app.files)) {
48
+ const filePath = path.join(exportDirectory, 'apps', app.folder, fileName);
49
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
50
+ if (typeof content === 'string' || Buffer.isBuffer(content)) {
51
+ fs.writeFileSync(filePath, content);
52
+ }
53
+ else {
54
+ fs.writeFileSync(filePath, JSON.stringify(content));
55
+ }
56
+ }
57
+ }
58
+ const buffer = await zip.zipFolder(exportDirectory);
59
+ exportFile = path.join(fixtureDirectory, 'export.zip');
60
+ fs.writeFileSync(exportFile, buffer);
61
+ };
62
+ const getJsonOutput = () => {
63
+ const jsonCall = consoleLogSpy.mock.calls.find((call) => {
64
+ try {
65
+ JSON.parse(call[0]);
66
+ return true;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ });
72
+ expect(jsonCall).toBeDefined();
73
+ return JSON.parse(jsonCall?.[0]);
74
+ };
75
+ it('should import an app with all resources', async () => {
76
+ await writeExportFile([
77
+ {
78
+ folder: 'My App-6668c18c',
79
+ files: {
80
+ 'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
81
+ 'repo-association.json': {
82
+ gitProvider: 'github',
83
+ cloneUrl: 'https://github.com/robingenz/appflow-export-test.git',
84
+ },
85
+ 'environments.json': [{ id: 1, name: 'Production', vars: { KEY: 'value' }, secrets: { API_KEY: 'secret' } }],
86
+ 'live-update-channels.json': [{ id: 'channel-uuid', name: 'Production' }],
87
+ 'native-configs.json': [
88
+ { id: 2, name: 'Production', configs: { base: { name: 'My App', bundle_id: 'dev.example.app' } } },
89
+ ],
90
+ 'native-build-automations.json': [
91
+ {
92
+ name: 'Android Release',
93
+ gitBranch: 'main',
94
+ platform: 'android',
95
+ buildType: 'release',
96
+ environmentId: 1,
97
+ webhook: null,
98
+ automationEnabled: false,
99
+ nativeConfigId: 2,
100
+ signingCertificateId: 3,
101
+ destinationId: null,
102
+ },
103
+ ],
104
+ 'web-build-automations.json': [],
105
+ 'signing-certificates/android/Debug-3/android-signing-certificate.json': {
106
+ id: 3,
107
+ name: 'Debug',
108
+ keystoreFile: 'keystore.jks',
109
+ keystorePassword: 'test1234',
110
+ keyAlias: 'key',
111
+ keyPassword: 'test1234',
112
+ },
113
+ 'signing-certificates/android/Debug-3/keystore.jks': Buffer.from('keystore'),
114
+ },
115
+ },
116
+ ]);
117
+ const appId = 'app-456';
118
+ const scope = nock(DEFAULT_API_BASE_URL)
119
+ .get('/v1/apps')
120
+ .query({ organizationId, limit: 50, offset: 0 })
121
+ .reply(200, [])
122
+ .post('/v1/apps', { name: 'My App', type: 'capacitor' })
123
+ .query({ organizationId })
124
+ .reply(201, { id: appId, name: 'My App', type: 'capacitor' })
125
+ .post(`/v1/apps/${appId}/certificates`)
126
+ .reply(201, { id: 'certificate-1', name: 'Debug' })
127
+ .post(`/v1/apps/${appId}/environments`, { appId, name: 'Production' })
128
+ .reply(201, { id: 'environment-1', name: 'Production' })
129
+ .post(`/v1/apps/${appId}/environments/environment-1/variables/set`, [{ key: 'KEY', value: 'value' }])
130
+ .reply(200)
131
+ .post(`/v1/apps/${appId}/environments/environment-1/secrets/set`, [{ key: 'API_KEY', value: 'secret' }])
132
+ .reply(200)
133
+ .get(`/v1/apps/${appId}/channels`)
134
+ .reply(200, [])
135
+ .post(`/v1/apps/${appId}/channels`, { appId, name: 'Production' })
136
+ .reply(201, { id: 'channel-1', name: 'Production' })
137
+ .post(`/v1/apps/${appId}/configurations`, {
138
+ name: 'Production',
139
+ displayName: 'My App',
140
+ packageName: 'dev.example.app',
141
+ })
142
+ .reply(201, { id: 'configuration-1', name: 'Production' })
143
+ .post(`/v1/apps/${appId}/automations`, (body) => {
144
+ return (body.name === 'Android Release' &&
145
+ body.platform === 'android' &&
146
+ body.triggerType === 'branch' &&
147
+ body.triggerPattern === 'main' &&
148
+ body.buildType === 'release' &&
149
+ body.enabled === false &&
150
+ body.appCertificateName === 'Debug' &&
151
+ body.appConfigurationName === 'Production' &&
152
+ body.appEnvironmentName === 'Production');
153
+ })
154
+ .reply(201, { id: 'automation-1', name: 'Android Release' })
155
+ .put(`/v1/apps/${appId}/repository`, {
156
+ ownerSlug: 'robingenz',
157
+ provider: 'github',
158
+ repositorySlug: 'appflow-export-test',
159
+ })
160
+ .reply(200, { id: appId });
161
+ await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
162
+ expect(scope.isDone()).toBe(true);
163
+ const output = getJsonOutput();
164
+ expect(output.dryRun).toBe(false);
165
+ expect(output.apps).toHaveLength(1);
166
+ expect(output.apps[0]).toMatchObject({
167
+ id: appId,
168
+ name: 'My App',
169
+ sourceId: '6668c18c',
170
+ sourceName: 'My App',
171
+ created: { automations: 1, certificates: 1, channels: 1, configurations: 1, destinations: 0, environments: 1 },
172
+ errors: [],
173
+ });
174
+ expect(output.apps[0].webUrl).toContain(appId);
175
+ });
176
+ it('should rename the app if the name is already taken', async () => {
177
+ await writeExportFile([
178
+ {
179
+ folder: 'My App-6668c18c',
180
+ files: { 'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' } },
181
+ },
182
+ ]);
183
+ const scope = nock(DEFAULT_API_BASE_URL)
184
+ .get('/v1/apps')
185
+ .query({ organizationId, limit: 50, offset: 0 })
186
+ .reply(200, [{ id: 'app-1', name: 'My App', type: 'capacitor' }])
187
+ .post('/v1/apps', { name: 'My App (2)', type: 'capacitor' })
188
+ .query({ organizationId })
189
+ .reply(201, { id: 'app-789', name: 'My App (2)', type: 'capacitor' })
190
+ .get('/v1/apps/app-789/channels')
191
+ .reply(200, []);
192
+ await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
193
+ expect(scope.isDone()).toBe(true);
194
+ const output = getJsonOutput();
195
+ expect(output.apps[0].name).toBe('My App (2)');
196
+ expect(output.apps[0].notes).toContainEqual(expect.stringContaining('`My App (2)`'));
197
+ });
198
+ it('should continue with the remaining resources if one fails', async () => {
199
+ await writeExportFile([
200
+ {
201
+ folder: 'My App-6668c18c',
202
+ files: {
203
+ 'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
204
+ 'environments.json': [{ id: 1, name: 'Production', vars: { KEY: 'value' }, secrets: null }],
205
+ 'live-update-channels.json': [{ id: 'channel-uuid', name: 'Production' }],
206
+ },
207
+ },
208
+ ]);
209
+ const appId = 'app-456';
210
+ const scope = nock(DEFAULT_API_BASE_URL)
211
+ .get('/v1/apps')
212
+ .query({ organizationId, limit: 50, offset: 0 })
213
+ .reply(200, [])
214
+ .post('/v1/apps', { name: 'My App', type: 'capacitor' })
215
+ .query({ organizationId })
216
+ .reply(201, { id: appId, name: 'My App', type: 'capacitor' })
217
+ .post(`/v1/apps/${appId}/environments`, { appId, name: 'Production' })
218
+ .reply(400, { message: 'Bad Request' })
219
+ .get(`/v1/apps/${appId}/channels`)
220
+ .reply(200, [])
221
+ .post(`/v1/apps/${appId}/channels`, { appId, name: 'Production' })
222
+ .reply(201, { id: 'channel-1', name: 'Production' });
223
+ await expect(importCommand.action({ file: exportFile, organizationId, json: true }, undefined)).rejects.toThrow('Process exited with code 1');
224
+ expect(scope.isDone()).toBe(true);
225
+ const output = getJsonOutput();
226
+ expect(output.apps[0].created).toMatchObject({ channels: 1, environments: 0 });
227
+ expect(output.apps[0].errors).toContainEqual(expect.stringContaining('Production'));
228
+ });
229
+ it('should not create any resources with the dry run option', async () => {
230
+ await writeExportFile([
231
+ {
232
+ folder: 'My App-6668c18c',
233
+ files: {
234
+ 'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
235
+ 'environments.json': [{ id: 1, name: 'Production', vars: { KEY: 'value' }, secrets: null }],
236
+ },
237
+ },
238
+ ]);
239
+ const scope = nock(DEFAULT_API_BASE_URL)
240
+ .get('/v1/apps')
241
+ .query({ organizationId, limit: 50, offset: 0 })
242
+ .reply(200, []);
243
+ await importCommand.action({ file: exportFile, organizationId, dryRun: true, json: true }, undefined);
244
+ expect(scope.isDone()).toBe(true);
245
+ const output = getJsonOutput();
246
+ expect(output.dryRun).toBe(true);
247
+ expect(output.apps[0].id).toBeNull();
248
+ expect(output.apps[0].webUrl).toBeNull();
249
+ });
250
+ it('should only import the apps matching the include filters', async () => {
251
+ await writeExportFile([
252
+ {
253
+ folder: 'App One-11111111',
254
+ files: { 'app-detail.json': { id: '11111111', name: 'App One', appType: 'capacitor' } },
255
+ },
256
+ {
257
+ folder: 'App Two-22222222',
258
+ files: { 'app-detail.json': { id: '22222222', name: 'App Two', appType: 'capacitor' } },
259
+ },
260
+ {
261
+ folder: 'App Three-33333333',
262
+ files: { 'app-detail.json': { id: '33333333', name: 'App Three', appType: 'capacitor' } },
263
+ },
264
+ ]);
265
+ const scope = nock(DEFAULT_API_BASE_URL)
266
+ .get('/v1/apps')
267
+ .query({ organizationId, limit: 50, offset: 0 })
268
+ .reply(200, [])
269
+ .post('/v1/apps', { name: 'App One', type: 'capacitor' })
270
+ .query({ organizationId })
271
+ .reply(201, { id: 'app-1', name: 'App One', type: 'capacitor' })
272
+ .get('/v1/apps/app-1/channels')
273
+ .reply(200, [])
274
+ .post('/v1/apps', { name: 'App Three', type: 'capacitor' })
275
+ .query({ organizationId })
276
+ .reply(201, { id: 'app-3', name: 'App Three', type: 'capacitor' })
277
+ .get('/v1/apps/app-3/channels')
278
+ .reply(200, []);
279
+ await importCommand.action({ file: exportFile, organizationId, include: ['App One,33333333'], json: true }, undefined);
280
+ expect(scope.isDone()).toBe(true);
281
+ const output = getJsonOutput();
282
+ expect(output.apps).toHaveLength(2);
283
+ expect(output.apps.map((app) => app.sourceName)).toEqual(['App One', 'App Three']);
284
+ });
285
+ it('should report skipped apps with an unsupported app type', async () => {
286
+ await writeExportFile([
287
+ {
288
+ folder: 'RN App-22222222',
289
+ files: { 'app-detail.json': { id: '22222222', name: 'RN App', appType: 'react_native' } },
290
+ },
291
+ ]);
292
+ const scope = nock(DEFAULT_API_BASE_URL)
293
+ .get('/v1/apps')
294
+ .query({ organizationId, limit: 50, offset: 0 })
295
+ .reply(200, []);
296
+ await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
297
+ expect(scope.isDone()).toBe(true);
298
+ const output = getJsonOutput();
299
+ expect(output.apps).toEqual([]);
300
+ expect(output.skippedApps).toEqual([
301
+ { sourceId: '22222222', sourceName: 'RN App', reason: expect.stringContaining('react_native') },
302
+ ]);
303
+ });
304
+ it('should error in non-interactive environment if no file is provided', async () => {
305
+ await expect(importCommand.action({ organizationId }, undefined)).rejects.toThrow('Process exited with code 1');
306
+ expect(vi.mocked(consola).error).toHaveBeenCalledWith('You must provide the export file path when running in non-interactive environment.');
307
+ });
308
+ });
package/dist/index.js CHANGED
@@ -26,16 +26,24 @@ const config = defineConfig({
26
26
  'apps:create': await import('./commands/apps/create.js').then((mod) => mod.default),
27
27
  'apps:delete': await import('./commands/apps/delete.js').then((mod) => mod.default),
28
28
  'apps:get': await import('./commands/apps/get.js').then((mod) => mod.default),
29
+ // Relative path because tsc-alias fails to rewrite the alias after the key `'apps:import'`
30
+ 'apps:import': await import('./commands/apps/import.js').then((mod) => mod.default),
29
31
  'apps:link': await import('./commands/apps/link.js').then((mod) => mod.default),
30
32
  'apps:list': await import('./commands/apps/list.js').then((mod) => mod.default),
31
33
  'apps:transfer': await import('./commands/apps/transfer.js').then((mod) => mod.default),
32
34
  'apps:unlink': await import('./commands/apps/unlink.js').then((mod) => mod.default),
35
+ 'apps:automations:create': await import('./commands/apps/automations/create.js').then((mod) => mod.default),
36
+ 'apps:automations:delete': await import('./commands/apps/automations/delete.js').then((mod) => mod.default),
37
+ 'apps:automations:get': await import('./commands/apps/automations/get.js').then((mod) => mod.default),
38
+ 'apps:automations:list': await import('./commands/apps/automations/list.js').then((mod) => mod.default),
39
+ 'apps:automations:update': await import('./commands/apps/automations/update.js').then((mod) => mod.default),
33
40
  'apps:builds:cancel': await import('./commands/apps/builds/cancel.js').then((mod) => mod.default),
34
41
  'apps:builds:create': await import('./commands/apps/builds/create.js').then((mod) => mod.default),
35
42
  'apps:builds:failure-summary': await import('./commands/apps/builds/failure-summary.js').then((mod) => mod.default),
36
43
  'apps:builds:get': await import('./commands/apps/builds/get.js').then((mod) => mod.default),
37
44
  'apps:builds:list': await import('./commands/apps/builds/list.js').then((mod) => mod.default),
38
45
  'apps:builds:logs': await import('./commands/apps/builds/logs.js').then((mod) => mod.default),
46
+ 'apps:builds:run': await import('./commands/apps/builds/run.js').then((mod) => mod.default),
39
47
  'apps:builds:share': await import('./commands/apps/builds/share.js').then((mod) => mod.default),
40
48
  'apps:builds:unshare': await import('./commands/apps/builds/unshare.js').then((mod) => mod.default),
41
49
  'apps:builds:download': await import('./commands/apps/builds/download.js').then((mod) => mod.default),
@@ -54,6 +62,11 @@ const config = defineConfig({
54
62
  'apps:channels:pause': await import('./commands/apps/channels/pause.js').then((mod) => mod.default),
55
63
  'apps:channels:resume': await import('./commands/apps/channels/resume.js').then((mod) => mod.default),
56
64
  'apps:channels:update': await import('./commands/apps/channels/update.js').then((mod) => mod.default),
65
+ 'apps:configurations:create': await import('./commands/apps/configurations/create.js').then((mod) => mod.default),
66
+ 'apps:configurations:delete': await import('./commands/apps/configurations/delete.js').then((mod) => mod.default),
67
+ 'apps:configurations:get': await import('./commands/apps/configurations/get.js').then((mod) => mod.default),
68
+ 'apps:configurations:list': await import('./commands/apps/configurations/list.js').then((mod) => mod.default),
69
+ 'apps:configurations:update': await import('./commands/apps/configurations/update.js').then((mod) => mod.default),
57
70
  'apps:deployments:create': await import('./commands/apps/deployments/create.js').then((mod) => mod.default),
58
71
  'apps:deployments:cancel': await import('./commands/apps/deployments/cancel.js').then((mod) => mod.default),
59
72
  'apps:deployments:failure-summary': await import('./commands/apps/deployments/failure-summary.js').then((mod) => mod.default),
@@ -0,0 +1,64 @@
1
+ import authorizationService from '../services/authorization-service.js';
2
+ import httpClient from '../utils/http-client.js';
3
+ class AppAutomationsServiceImpl {
4
+ httpClient;
5
+ constructor(httpClient) {
6
+ this.httpClient = httpClient;
7
+ }
8
+ async create(dto) {
9
+ const { appId, ...bodyData } = dto;
10
+ const response = await this.httpClient.post(`/v1/apps/${appId}/automations`, bodyData, {
11
+ headers: {
12
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
13
+ },
14
+ });
15
+ return response.data;
16
+ }
17
+ async delete(dto) {
18
+ await this.httpClient.delete(`/v1/apps/${dto.appId}/automations/${dto.automationId}`, {
19
+ headers: {
20
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
21
+ },
22
+ });
23
+ }
24
+ async findAll(dto) {
25
+ const params = {};
26
+ if (dto.limit !== undefined) {
27
+ params.limit = dto.limit.toString();
28
+ }
29
+ if (dto.name) {
30
+ params.name = dto.name;
31
+ }
32
+ if (dto.offset !== undefined) {
33
+ params.offset = dto.offset.toString();
34
+ }
35
+ if (dto.platform) {
36
+ params.platform = dto.platform;
37
+ }
38
+ const response = await this.httpClient.get(`/v1/apps/${dto.appId}/automations`, {
39
+ headers: {
40
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
41
+ },
42
+ params,
43
+ });
44
+ return response.data;
45
+ }
46
+ async findOneById(dto) {
47
+ const response = await this.httpClient.get(`/v1/apps/${dto.appId}/automations/${dto.automationId}`, {
48
+ headers: {
49
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
50
+ },
51
+ });
52
+ return response.data;
53
+ }
54
+ async update(dto) {
55
+ const { appId, automationId, ...bodyData } = dto;
56
+ await this.httpClient.patch(`/v1/apps/${appId}/automations/${automationId}`, bodyData, {
57
+ headers: {
58
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
59
+ },
60
+ });
61
+ }
62
+ }
63
+ const appAutomationsService = new AppAutomationsServiceImpl(httpClient);
64
+ export default appAutomationsService;
@@ -0,0 +1,77 @@
1
+ import authorizationService from '../services/authorization-service.js';
2
+ import httpClient from '../utils/http-client.js';
3
+ class AppConfigurationsServiceImpl {
4
+ httpClient;
5
+ constructor(httpClient) {
6
+ this.httpClient = httpClient;
7
+ }
8
+ async create(dto) {
9
+ const { appId, ...bodyData } = dto;
10
+ const response = await this.httpClient.post(`/v1/apps/${appId}/configurations`, bodyData, {
11
+ headers: {
12
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
13
+ },
14
+ });
15
+ return response.data;
16
+ }
17
+ async delete(dto) {
18
+ if (dto.id) {
19
+ await this.httpClient.delete(`/v1/apps/${dto.appId}/configurations/${dto.id}`, {
20
+ headers: {
21
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
22
+ },
23
+ });
24
+ }
25
+ else if (dto.name) {
26
+ await this.httpClient.delete(`/v1/apps/${dto.appId}/configurations`, {
27
+ headers: {
28
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
29
+ },
30
+ params: {
31
+ name: dto.name,
32
+ },
33
+ });
34
+ }
35
+ }
36
+ async findAll(dto) {
37
+ const params = {};
38
+ if (dto.limit !== undefined) {
39
+ params.limit = dto.limit.toString();
40
+ }
41
+ if (dto.name) {
42
+ params.name = dto.name;
43
+ }
44
+ if (dto.offset !== undefined) {
45
+ params.offset = dto.offset.toString();
46
+ }
47
+ if (dto.query) {
48
+ params.query = dto.query;
49
+ }
50
+ const response = await this.httpClient.get(`/v1/apps/${dto.appId}/configurations`, {
51
+ headers: {
52
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
53
+ },
54
+ params,
55
+ });
56
+ return response.data;
57
+ }
58
+ async findOneById(dto) {
59
+ const response = await this.httpClient.get(`/v1/apps/${dto.appId}/configurations/${dto.id}`, {
60
+ headers: {
61
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
62
+ },
63
+ });
64
+ return response.data;
65
+ }
66
+ async update(dto) {
67
+ const { appId, configurationId, ...bodyData } = dto;
68
+ const response = await this.httpClient.patch(`/v1/apps/${appId}/configurations/${configurationId}`, bodyData, {
69
+ headers: {
70
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
71
+ },
72
+ });
73
+ return response.data;
74
+ }
75
+ }
76
+ const appConfigurationsService = new AppConfigurationsServiceImpl(httpClient);
77
+ export default appConfigurationsService;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,5 @@
1
1
  export * from './app-apple-api-key.js';
2
+ export * from './app-automation.js';
2
3
  export * from './app-build-source.js';
3
4
  export * from './app-bundle.js';
4
5
  export * from './app-certificate.js';
@@ -0,0 +1,170 @@
1
+ import { getCodeFromUnknownError, UserError } from '../utils/error.js';
2
+ import { wait } from '../utils/wait.js';
3
+ import { execFileSync, spawn } from 'child_process';
4
+ import fs from 'fs';
5
+ import os from 'os';
6
+ import path from 'path';
7
+ const BOOT_POLL_INTERVAL_IN_MS = 2000;
8
+ const BOOT_TIMEOUT_IN_MS = 300000;
9
+ /**
10
+ * Find all Android emulators (AVDs) installed on this machine.
11
+ */
12
+ export const findAllAndroidEmulators = () => {
13
+ const ids = runEmulator(['-list-avds'])
14
+ .split('\n')
15
+ .map((line) => line.trim())
16
+ .filter(Boolean);
17
+ const serialById = findAllRunningAndroidEmulators();
18
+ return ids.map((id) => {
19
+ const serial = serialById.get(id) ?? null;
20
+ const { displayName, sdkVersion } = getAndroidEmulatorConfig(id);
21
+ return {
22
+ id,
23
+ name: displayName ?? id,
24
+ running: !!serial,
25
+ sdkVersion,
26
+ serial,
27
+ };
28
+ });
29
+ };
30
+ /**
31
+ * Boot an Android emulator and wait until it has finished booting.
32
+ *
33
+ * Returns the serial of the running emulator.
34
+ */
35
+ export const bootAndroidEmulator = async (emulator) => {
36
+ if (emulator.serial) {
37
+ return emulator.serial;
38
+ }
39
+ const child = spawn(getAndroidToolPath('emulator', 'emulator'), ['-avd', emulator.id], {
40
+ detached: true,
41
+ stdio: 'ignore',
42
+ });
43
+ child.unref();
44
+ const deadline = Date.now() + BOOT_TIMEOUT_IN_MS;
45
+ while (Date.now() < deadline) {
46
+ await wait(BOOT_POLL_INTERVAL_IN_MS);
47
+ const serial = findAllRunningAndroidEmulators().get(emulator.id);
48
+ if (serial && isAndroidEmulatorBooted(serial)) {
49
+ return serial;
50
+ }
51
+ }
52
+ throw new UserError(`The emulator "${emulator.name}" did not finish booting in time.`);
53
+ };
54
+ /**
55
+ * Install an APK on a running Android emulator.
56
+ */
57
+ export const installAndroidApp = (serial, apkPath) => {
58
+ runAdb(['-s', serial, 'install', '-r', apkPath]);
59
+ };
60
+ /**
61
+ * Launch an app on a running Android emulator.
62
+ */
63
+ export const launchAndroidApp = (serial, packageName) => {
64
+ runAdb(['-s', serial, 'shell', 'monkey', '-p', packageName, '-c', 'android.intent.category.LAUNCHER', '1']);
65
+ };
66
+ /**
67
+ * Map the AVD ID of every running emulator to its serial.
68
+ */
69
+ const findAllRunningAndroidEmulators = () => {
70
+ const serials = runAdb(['devices'])
71
+ .split('\n')
72
+ .slice(1)
73
+ .map((line) => line.split('\t')[0]?.trim())
74
+ .filter((serial) => !!serial && serial.startsWith('emulator-'));
75
+ const serialById = new Map();
76
+ for (const serial of serials) {
77
+ if (!serial) {
78
+ continue;
79
+ }
80
+ try {
81
+ const id = runAdb(['-s', serial, 'emu', 'avd', 'name']).split('\n')[0]?.trim();
82
+ if (id) {
83
+ serialById.set(id, serial);
84
+ }
85
+ }
86
+ catch {
87
+ // Ignore emulators that do not respond to the console command.
88
+ }
89
+ }
90
+ return serialById;
91
+ };
92
+ /**
93
+ * Read the display name and API level of an emulator from its AVD configuration.
94
+ */
95
+ const getAndroidEmulatorConfig = (id) => {
96
+ try {
97
+ const config = fs.readFileSync(path.join(getAndroidAvdHome(), `${id}.avd`, 'config.ini'), 'utf-8');
98
+ return {
99
+ displayName: config.match(/^avd\.ini\.displayname=(.+)$/m)?.[1]?.trim() ?? null,
100
+ sdkVersion: config.match(/android-(\d+)/)?.[1] ?? null,
101
+ };
102
+ }
103
+ catch {
104
+ return { displayName: null, sdkVersion: null };
105
+ }
106
+ };
107
+ const getAndroidAvdHome = () => {
108
+ const avdHome = process.env.ANDROID_AVD_HOME;
109
+ if (avdHome) {
110
+ return avdHome;
111
+ }
112
+ const sdkHome = process.env.ANDROID_SDK_HOME;
113
+ return sdkHome ? path.join(sdkHome, '.android', 'avd') : path.join(os.homedir(), '.android', 'avd');
114
+ };
115
+ const isAndroidEmulatorBooted = (serial) => {
116
+ try {
117
+ return runAdb(['-s', serial, 'shell', 'getprop', 'sys.boot_completed']).trim() === '1';
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ };
123
+ const runAdb = (args) => run(getAndroidToolPath('platform-tools', 'adb'), args, 'adb');
124
+ const runEmulator = (args) => run(getAndroidToolPath('emulator', 'emulator'), args, 'emulator');
125
+ const run = (command, args, toolName) => {
126
+ try {
127
+ return execFileSync(command, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
128
+ }
129
+ catch (error) {
130
+ if (getCodeFromUnknownError(error) === 'ENOENT') {
131
+ throw new UserError(`Could not find "${toolName}". Make sure the Android SDK is installed and either the ANDROID_HOME or ANDROID_SDK_ROOT environment variable points to it.`);
132
+ }
133
+ const stderr = error.stderr?.trim();
134
+ throw new UserError(stderr ? `The command "${toolName}" failed: ${stderr}` : `The command "${toolName}" failed.`);
135
+ }
136
+ };
137
+ /**
138
+ * Resolve an Android SDK tool, falling back to the `PATH` if the SDK cannot be located.
139
+ */
140
+ const getAndroidToolPath = (directory, binary) => {
141
+ const sdkRoot = getAndroidSdkRoot();
142
+ if (sdkRoot) {
143
+ const toolPath = path.join(sdkRoot, directory, binary);
144
+ if (fs.existsSync(toolPath)) {
145
+ return toolPath;
146
+ }
147
+ }
148
+ return binary;
149
+ };
150
+ const getAndroidSdkRoot = () => {
151
+ const sdkRoot = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
152
+ if (sdkRoot) {
153
+ return sdkRoot;
154
+ }
155
+ const defaultSdkRoot = getDefaultAndroidSdkRoot();
156
+ return fs.existsSync(defaultSdkRoot) ? defaultSdkRoot : undefined;
157
+ };
158
+ const getDefaultAndroidSdkRoot = () => {
159
+ switch (process.platform) {
160
+ case 'darwin': {
161
+ return path.join(os.homedir(), 'Library', 'Android', 'sdk');
162
+ }
163
+ case 'win32': {
164
+ return path.join(os.homedir(), 'AppData', 'Local', 'Android', 'Sdk');
165
+ }
166
+ default: {
167
+ return path.join(os.homedir(), 'Android', 'Sdk');
168
+ }
169
+ }
170
+ };