@capawesome/cli 4.18.3 → 4.19.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.
@@ -0,0 +1,375 @@
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
+ JSON.stringify(body.triggerPatterns) === JSON.stringify(['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 importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
224
+ expect(process.exitCode).toBe(1);
225
+ process.exitCode = undefined;
226
+ expect(scope.isDone()).toBe(true);
227
+ const output = getJsonOutput();
228
+ expect(output.apps[0].created).toMatchObject({ channels: 1, environments: 0 });
229
+ expect(output.apps[0].errors).toContainEqual(expect.stringContaining('Production'));
230
+ });
231
+ it('should not create any resources with the dry run option', async () => {
232
+ await writeExportFile([
233
+ {
234
+ folder: 'My App-6668c18c',
235
+ files: {
236
+ 'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
237
+ 'environments.json': [{ id: 1, name: 'Production', vars: { KEY: 'value' }, secrets: null }],
238
+ },
239
+ },
240
+ ]);
241
+ const scope = nock(DEFAULT_API_BASE_URL)
242
+ .get('/v1/apps')
243
+ .query({ organizationId, limit: 50, offset: 0 })
244
+ .reply(200, []);
245
+ await importCommand.action({ file: exportFile, organizationId, dryRun: true, json: true }, undefined);
246
+ expect(scope.isDone()).toBe(true);
247
+ const output = getJsonOutput();
248
+ expect(output.dryRun).toBe(true);
249
+ expect(output.apps[0].id).toBeNull();
250
+ expect(output.apps[0].webUrl).toBeNull();
251
+ });
252
+ it('should only import the apps matching the include filters', async () => {
253
+ await writeExportFile([
254
+ {
255
+ folder: 'App One-11111111',
256
+ files: { 'app-detail.json': { id: '11111111', name: 'App One', appType: 'capacitor' } },
257
+ },
258
+ {
259
+ folder: 'App Two-22222222',
260
+ files: { 'app-detail.json': { id: '22222222', name: 'App Two', appType: 'capacitor' } },
261
+ },
262
+ {
263
+ folder: 'App Three-33333333',
264
+ files: { 'app-detail.json': { id: '33333333', name: 'App Three', appType: 'capacitor' } },
265
+ },
266
+ ]);
267
+ const scope = nock(DEFAULT_API_BASE_URL)
268
+ .get('/v1/apps')
269
+ .query({ organizationId, limit: 50, offset: 0 })
270
+ .reply(200, [])
271
+ .post('/v1/apps', { name: 'App One', type: 'capacitor' })
272
+ .query({ organizationId })
273
+ .reply(201, { id: 'app-1', name: 'App One', type: 'capacitor' })
274
+ .get('/v1/apps/app-1/channels')
275
+ .reply(200, [])
276
+ .post('/v1/apps', { name: 'App Three', type: 'capacitor' })
277
+ .query({ organizationId })
278
+ .reply(201, { id: 'app-3', name: 'App Three', type: 'capacitor' })
279
+ .get('/v1/apps/app-3/channels')
280
+ .reply(200, []);
281
+ await importCommand.action({ file: exportFile, organizationId, include: ['App One,33333333'], json: true }, undefined);
282
+ expect(scope.isDone()).toBe(true);
283
+ const output = getJsonOutput();
284
+ expect(output.apps).toHaveLength(2);
285
+ expect(output.apps.map((app) => app.sourceName)).toEqual(['App One', 'App Three']);
286
+ });
287
+ it('should set the next build number for apps with a latest build number', async () => {
288
+ await writeExportFile([
289
+ {
290
+ folder: 'My App-6668c18c',
291
+ files: {
292
+ 'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor', latestBuildNumber: 142 },
293
+ },
294
+ },
295
+ ]);
296
+ const scope = nock(DEFAULT_API_BASE_URL)
297
+ .get('/v1/apps')
298
+ .query({ organizationId, limit: 50, offset: 0 })
299
+ .reply(200, [])
300
+ .post('/v1/apps', { name: 'My App', type: 'capacitor' })
301
+ .query({ organizationId })
302
+ .reply(201, { id: 'app-1', name: 'My App', type: 'capacitor' })
303
+ .patch('/v1/apps/app-1', { nextAppBuildNumber: 143 })
304
+ .reply(200, { id: 'app-1', name: 'My App', type: 'capacitor' })
305
+ .get('/v1/apps/app-1/channels')
306
+ .reply(200, []);
307
+ await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
308
+ expect(scope.isDone()).toBe(true);
309
+ const output = getJsonOutput();
310
+ expect(output.apps[0].notes).toContainEqual(expect.stringContaining('`143`'));
311
+ });
312
+ it('should resolve the ambiguous app type `ionic` with the ionic app type option', async () => {
313
+ await writeExportFile([
314
+ {
315
+ folder: 'Legacy App-11111111',
316
+ files: { 'app-detail.json': { id: '11111111', name: 'Legacy App', appType: 'ionic' } },
317
+ },
318
+ ]);
319
+ const scope = nock(DEFAULT_API_BASE_URL)
320
+ .get('/v1/apps')
321
+ .query({ organizationId, limit: 50, offset: 0 })
322
+ .reply(200, [])
323
+ .post('/v1/apps', { name: 'Legacy App', type: 'cordova' })
324
+ .query({ organizationId })
325
+ .reply(201, { id: 'app-1', name: 'Legacy App', type: 'cordova' })
326
+ .get('/v1/apps/app-1/channels')
327
+ .reply(200, []);
328
+ await importCommand.action({ file: exportFile, organizationId, ionicAppType: 'cordova', json: true }, undefined);
329
+ expect(scope.isDone()).toBe(true);
330
+ const output = getJsonOutput();
331
+ expect(output.apps[0].notes).toContainEqual(expect.stringContaining('`cordova`'));
332
+ });
333
+ it('should skip apps with the ambiguous app type `ionic` in non-interactive environment without the ionic app type option', async () => {
334
+ await writeExportFile([
335
+ {
336
+ folder: 'Legacy App-11111111',
337
+ files: { 'app-detail.json': { id: '11111111', name: 'Legacy App', appType: 'ionic' } },
338
+ },
339
+ ]);
340
+ const scope = nock(DEFAULT_API_BASE_URL)
341
+ .get('/v1/apps')
342
+ .query({ organizationId, limit: 50, offset: 0 })
343
+ .reply(200, []);
344
+ await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
345
+ expect(scope.isDone()).toBe(true);
346
+ const output = getJsonOutput();
347
+ expect(output.apps).toEqual([]);
348
+ expect(output.skippedApps).toEqual([
349
+ { sourceId: '11111111', sourceName: 'Legacy App', reason: expect.stringContaining('--ionic-app-type') },
350
+ ]);
351
+ });
352
+ it('should report skipped apps with an unsupported app type', async () => {
353
+ await writeExportFile([
354
+ {
355
+ folder: 'RN App-22222222',
356
+ files: { 'app-detail.json': { id: '22222222', name: 'RN App', appType: 'react_native' } },
357
+ },
358
+ ]);
359
+ const scope = nock(DEFAULT_API_BASE_URL)
360
+ .get('/v1/apps')
361
+ .query({ organizationId, limit: 50, offset: 0 })
362
+ .reply(200, []);
363
+ await importCommand.action({ file: exportFile, organizationId, json: true }, undefined);
364
+ expect(scope.isDone()).toBe(true);
365
+ const output = getJsonOutput();
366
+ expect(output.apps).toEqual([]);
367
+ expect(output.skippedApps).toEqual([
368
+ { sourceId: '22222222', sourceName: 'RN App', reason: expect.stringContaining('react_native') },
369
+ ]);
370
+ });
371
+ it('should error in non-interactive environment if no file is provided', async () => {
372
+ await expect(importCommand.action({ organizationId }, undefined)).rejects.toThrow('Process exited with code 1');
373
+ expect(vi.mocked(consola).error).toHaveBeenCalledWith('You must provide the export file path when running in non-interactive environment.');
374
+ });
375
+ });
@@ -7,7 +7,7 @@ import { parseCustomProperties } from '../../../utils/custom-properties.js';
7
7
  import { defineCommand, defineOptions } from 'zodline';
8
8
  import { createBufferFromPath, createBufferFromReadStream, createBufferFromString, isPrivateKeyContent, } from '../../../utils/buffer.js';
9
9
  import { isInteractive } from '../../../utils/environment.js';
10
- import { directoryContainsSourceMaps, directoryContainsSymlinks, isReadable, getFilesInDirectoryAndSubdirectories, isDirectory, } from '../../../utils/file.js';
10
+ import { directoryContainsSourceMaps, directoryContainsSymlinks, isReadable, getFilesInDirectoryAndSubdirectories, isDirectory, readFileFromDirectory, } from '../../../utils/file.js';
11
11
  import { createHash } from '../../../utils/hash.js';
12
12
  import { generateManifestJson } from '../../../utils/manifest.js';
13
13
  import { formatPrivateKey } from '../../../utils/private-key.js';
@@ -351,7 +351,7 @@ const uploadFiles = async (options) => {
351
351
  const file = files[fileIndex];
352
352
  fileIndex++;
353
353
  consola.start(`Uploading file (${fileIndex}/${files.length})...`);
354
- const buffer = await createBufferFromPath(file.path);
354
+ const buffer = await readFileFromDirectory(file.path);
355
355
  await uploadFile({
356
356
  appId,
357
357
  appBundleId: appBundleId,
package/dist/index.js CHANGED
@@ -26,6 +26,8 @@ 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),
@@ -70,6 +70,15 @@ class AppsServiceImpl {
70
70
  },
71
71
  });
72
72
  }
73
+ async update(dto) {
74
+ const { appId, ...bodyData } = dto;
75
+ const response = await this.httpClient.patch(`/v1/apps/${appId}`, bodyData, {
76
+ headers: {
77
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
78
+ },
79
+ });
80
+ return response.data;
81
+ }
73
82
  }
74
83
  const appsService = new AppsServiceImpl(httpClient);
75
84
  export default appsService;
@@ -0,0 +1,10 @@
1
+ export const generateUniqueAppName = (name, takenNames) => {
2
+ if (!takenNames.has(name)) {
3
+ return name;
4
+ }
5
+ let counter = 2;
6
+ while (takenNames.has(`${name} (${counter})`)) {
7
+ counter++;
8
+ }
9
+ return `${name} (${counter})`;
10
+ };