@capawesome/cli 4.18.4 → 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,340 @@
1
+ import { parseAppflowExport } from '../utils/appflow-export.js';
2
+ import { UserError } from '../utils/error.js';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ describe('appflow-export', () => {
8
+ let exportDirectory;
9
+ beforeEach(() => {
10
+ exportDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'appflow-export-test-'));
11
+ });
12
+ afterEach(() => {
13
+ fs.rmSync(exportDirectory, { recursive: true, force: true });
14
+ });
15
+ const writeAppFiles = (appFolder, files) => {
16
+ for (const [fileName, content] of Object.entries(files)) {
17
+ const filePath = path.join(exportDirectory, 'apps', appFolder, fileName);
18
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
19
+ if (typeof content === 'string' || Buffer.isBuffer(content)) {
20
+ fs.writeFileSync(filePath, content);
21
+ }
22
+ else {
23
+ fs.writeFileSync(filePath, JSON.stringify(content));
24
+ }
25
+ }
26
+ };
27
+ it('should throw a user error if the export does not contain an apps directory', async () => {
28
+ await expect(parseAppflowExport(exportDirectory)).rejects.toThrow(UserError);
29
+ });
30
+ it('should parse a fully configured app', async () => {
31
+ writeAppFiles('My App-6668c18c', {
32
+ 'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
33
+ 'repo-association.json': {
34
+ gitProvider: 'github',
35
+ gitUsername: 'robingenz',
36
+ cloneUrl: 'https://github.com/robingenz/appflow-export-test.git',
37
+ },
38
+ 'environments.json': [
39
+ { id: 33513, name: 'Development', vars: { DEBUG: 'true' }, secrets: null },
40
+ { id: 33515, name: 'Production', vars: {}, secrets: { API_KEY: 'secret' } },
41
+ ],
42
+ 'live-update-channels.json': [
43
+ { id: 'd33204c6', name: 'Production' },
44
+ { id: '1e168146', name: 'Staging' },
45
+ ],
46
+ 'native-configs.json': [
47
+ {
48
+ id: 41087,
49
+ name: 'Development',
50
+ configs: {
51
+ base: { name: 'My App (Dev)', bundle_id: 'dev.robingenz.app.dev' },
52
+ ionic: { channel_name: 'Development', update_method: 'background' },
53
+ },
54
+ },
55
+ { id: 41089, name: 'Barebones', configs: { base: { bundle_id: 'dev.robingenz.app.bare' } } },
56
+ ],
57
+ 'native-build-automations.json': [
58
+ {
59
+ name: 'Android Release',
60
+ gitBranch: 'main',
61
+ type: 'package',
62
+ platform: 'android',
63
+ buildType: 'release',
64
+ environmentId: 33515,
65
+ webhook: 'https://example.com/webhook',
66
+ automationEnabled: true,
67
+ nativeConfigId: 41087,
68
+ signingCertificateId: 154267,
69
+ destinationId: null,
70
+ },
71
+ {
72
+ name: 'iOS Store',
73
+ gitBranch: 'main',
74
+ type: 'package',
75
+ platform: 'ios',
76
+ buildType: 'development',
77
+ environmentId: 99999,
78
+ webhook: null,
79
+ automationEnabled: false,
80
+ nativeConfigId: null,
81
+ signingCertificateId: null,
82
+ destinationId: null,
83
+ },
84
+ ],
85
+ 'web-build-automations.json': [
86
+ {
87
+ name: 'Web Prod',
88
+ gitBranch: 'main',
89
+ type: 'deploy',
90
+ platform: 'web-deploy',
91
+ environmentId: 33515,
92
+ webhook: null,
93
+ automationEnabled: true,
94
+ channelIds: ['1e168146', 'd33204c6'],
95
+ webPreviewEnabled: true,
96
+ },
97
+ ],
98
+ 'signing-certificates/android/Debug-154267/android-signing-certificate.json': {
99
+ id: 154267,
100
+ name: 'Debug',
101
+ keystoreFile: 'keystore.jks',
102
+ keystorePassword: 'test1234',
103
+ keyAlias: 'key',
104
+ keyPassword: 'test1234',
105
+ },
106
+ 'signing-certificates/android/Debug-154267/keystore.jks': Buffer.from('keystore'),
107
+ 'signing-certificates/ios/Dev-154266/ios-signing-certificate.json': {
108
+ id: 154266,
109
+ name: 'Dev',
110
+ p12File: 'ios-certificate.p12',
111
+ p12Password: '123456',
112
+ provisioningProfiles: ['Test.mobileprovision'],
113
+ },
114
+ 'signing-certificates/ios/Dev-154266/ios-certificate.p12': Buffer.from('p12'),
115
+ 'signing-certificates/ios/Dev-154266/Test.mobileprovision': Buffer.from('profile'),
116
+ 'store-destinations/android/Prod-27890/play-store-destination.json': {
117
+ id: 27890,
118
+ name: 'Prod',
119
+ artifactType: 'aab',
120
+ packageName: 'dev.robingenz.app',
121
+ track: 'internal',
122
+ releaseStatus: 'completed',
123
+ },
124
+ 'store-destinations/android/Prod-27890/json-key.json': { type: 'service_account' },
125
+ });
126
+ const { apps, skippedApps } = await parseAppflowExport(exportDirectory);
127
+ expect(skippedApps).toEqual([]);
128
+ expect(apps).toHaveLength(1);
129
+ const app = apps[0];
130
+ expect(app.sourceId).toBe('6668c18c');
131
+ expect(app.sourceName).toBe('My App');
132
+ expect(app.type).toBe('capacitor');
133
+ expect(app.repository).toEqual({
134
+ ownerSlug: 'robingenz',
135
+ provider: 'github',
136
+ repositorySlug: 'appflow-export-test',
137
+ projectSlug: undefined,
138
+ });
139
+ expect(app.environments).toEqual([
140
+ { name: 'Development', variables: [{ key: 'DEBUG', value: 'true' }], secrets: [] },
141
+ { name: 'Production', variables: [], secrets: [{ key: 'API_KEY', value: 'secret' }] },
142
+ ]);
143
+ expect(app.channels).toEqual(['Production', 'Staging']);
144
+ expect(app.configurations).toEqual([
145
+ { name: 'Development', displayName: 'My App (Dev)', packageName: 'dev.robingenz.app.dev' },
146
+ { name: 'Barebones', displayName: undefined, packageName: 'dev.robingenz.app.bare' },
147
+ ]);
148
+ expect(app.certificates).toHaveLength(2);
149
+ expect(app.certificates[0]).toMatchObject({
150
+ name: 'Debug',
151
+ platform: 'android',
152
+ password: 'test1234',
153
+ keyAlias: 'key',
154
+ keyPassword: 'test1234',
155
+ });
156
+ expect(app.certificates[1]).toMatchObject({ name: 'Dev', platform: 'ios', password: '123456' });
157
+ expect(app.certificates[1].provisioningProfilePaths).toHaveLength(1);
158
+ expect(app.destinations).toEqual([
159
+ {
160
+ name: 'Prod',
161
+ platform: 'android',
162
+ androidPackageName: 'dev.robingenz.app',
163
+ androidBuildArtifactType: 'aab',
164
+ androidReleaseStatus: 'completed',
165
+ googlePlayTrack: 'internal',
166
+ googleServiceAccountKeyPath: expect.stringContaining('json-key.json'),
167
+ },
168
+ ]);
169
+ expect(app.automations).toEqual([
170
+ {
171
+ name: 'Android Release',
172
+ platform: 'android',
173
+ triggerPattern: 'main',
174
+ buildType: 'release',
175
+ enabled: true,
176
+ appCertificateName: 'Debug',
177
+ appConfigurationName: 'Development',
178
+ appDestinationName: undefined,
179
+ appEnvironmentName: 'Production',
180
+ },
181
+ {
182
+ name: 'iOS Store',
183
+ platform: 'ios',
184
+ triggerPattern: 'main',
185
+ buildType: 'development',
186
+ enabled: false,
187
+ appCertificateName: undefined,
188
+ appConfigurationName: undefined,
189
+ appDestinationName: undefined,
190
+ appEnvironmentName: undefined,
191
+ },
192
+ {
193
+ name: 'Web Prod (Staging)',
194
+ platform: 'web',
195
+ triggerPattern: 'main',
196
+ enabled: true,
197
+ appChannelName: 'Staging',
198
+ appEnvironmentName: 'Production',
199
+ },
200
+ {
201
+ name: 'Web Prod (Production)',
202
+ platform: 'web',
203
+ triggerPattern: 'main',
204
+ enabled: true,
205
+ appChannelName: 'Production',
206
+ appEnvironmentName: 'Production',
207
+ },
208
+ ]);
209
+ expect(app.notes).toHaveLength(4);
210
+ expect(app.notes).toContainEqual(expect.stringContaining('webhook'));
211
+ expect(app.notes).toContainEqual(expect.stringContaining('unknown environment'));
212
+ expect(app.notes).toContainEqual(expect.stringContaining('web previews'));
213
+ expect(app.notes).toContainEqual(expect.stringContaining('Live Update plugin settings'));
214
+ });
215
+ it('should keep the ambiguous app type `ionic` as source app type', async () => {
216
+ writeAppFiles('Legacy App-11111111', {
217
+ 'app-detail.json': { id: '11111111', name: 'Legacy App', appType: 'ionic' },
218
+ });
219
+ const { apps } = await parseAppflowExport(exportDirectory);
220
+ expect(apps).toHaveLength(1);
221
+ expect(apps[0].sourceAppType).toBe('ionic');
222
+ expect(apps[0].notes).toEqual([]);
223
+ });
224
+ it('should skip apps with an unsupported app type', async () => {
225
+ writeAppFiles('RN App-22222222', {
226
+ 'app-detail.json': { id: '22222222', name: 'RN App', appType: 'react_native' },
227
+ });
228
+ writeAppFiles('Other App-33333333', {
229
+ 'app-detail.json': { id: '33333333', name: 'Other App', appType: 'unknown' },
230
+ });
231
+ const { apps, skippedApps } = await parseAppflowExport(exportDirectory);
232
+ expect(apps).toEqual([]);
233
+ expect(skippedApps).toContainEqual({
234
+ sourceId: '22222222',
235
+ sourceName: 'RN App',
236
+ reason: expect.stringContaining('react_native'),
237
+ retryLater: true,
238
+ });
239
+ expect(skippedApps).toContainEqual({
240
+ sourceId: '33333333',
241
+ sourceName: 'Other App',
242
+ reason: expect.stringContaining('unknown'),
243
+ retryLater: false,
244
+ });
245
+ });
246
+ it('should handle an empty repo association serialized as an array', async () => {
247
+ writeAppFiles('No Repo-44444444', {
248
+ 'app-detail.json': { id: '44444444', name: 'No Repo', appType: 'capacitor' },
249
+ 'repo-association.json': [],
250
+ });
251
+ const { apps } = await parseAppflowExport(exportDirectory);
252
+ expect(apps[0].repository).toBeNull();
253
+ });
254
+ it('should skip a signing certificate with a missing file', async () => {
255
+ writeAppFiles('Broken Cert-55555555', {
256
+ 'app-detail.json': { id: '55555555', name: 'Broken Cert', appType: 'capacitor' },
257
+ 'signing-certificates/android/Debug-1/android-signing-certificate.json': {
258
+ id: 1,
259
+ name: 'Debug',
260
+ keystoreFile: 'keystore.jks',
261
+ keystorePassword: 'test',
262
+ keyAlias: 'key',
263
+ keyPassword: 'test',
264
+ },
265
+ });
266
+ const { apps } = await parseAppflowExport(exportDirectory);
267
+ expect(apps[0].certificates).toEqual([]);
268
+ expect(apps[0].notes).toContainEqual(expect.stringContaining('keystore.jks'));
269
+ });
270
+ it.each(['v2.0.0', 'v10.0.0'])('should warn about the unsupported export format version %s', async (version) => {
271
+ writeAppFiles('My App-6668c18c', {
272
+ 'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
273
+ });
274
+ fs.writeFileSync(path.join(exportDirectory, 'manifest.json'), JSON.stringify({ version }));
275
+ const { warnings } = await parseAppflowExport(exportDirectory);
276
+ expect(warnings).toEqual([expect.stringContaining(`\`${version}\``)]);
277
+ });
278
+ it('should not warn about a supported export format version', async () => {
279
+ writeAppFiles('My App-6668c18c', {
280
+ 'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
281
+ });
282
+ fs.writeFileSync(path.join(exportDirectory, 'manifest.json'), JSON.stringify({ version: 'v1.0.0', exportedAt: '2026-08-24T15:07:00Z' }));
283
+ const { warnings } = await parseAppflowExport(exportDirectory);
284
+ expect(warnings).toEqual([]);
285
+ });
286
+ it('should not warn about a missing manifest', async () => {
287
+ writeAppFiles('My App-6668c18c', {
288
+ 'app-detail.json': { id: '6668c18c', name: 'My App', appType: 'capacitor' },
289
+ });
290
+ const { warnings } = await parseAppflowExport(exportDirectory);
291
+ expect(warnings).toEqual([]);
292
+ });
293
+ it('should skip certificate files that resolve outside the export', async () => {
294
+ writeAppFiles('Evil-77777777', {
295
+ 'app-detail.json': { id: '77777777', name: 'Evil', appType: 'capacitor' },
296
+ 'signing-certificates/android/Debug-1/android-signing-certificate.json': {
297
+ id: 1,
298
+ name: 'Debug',
299
+ keystoreFile: '../../../outside.jks',
300
+ keystorePassword: 'test',
301
+ keyAlias: 'key',
302
+ keyPassword: 'test',
303
+ },
304
+ 'signing-certificates/ios/Dev-2/ios-signing-certificate.json': {
305
+ id: 2,
306
+ name: 'Dev',
307
+ p12File: '/etc/hosts',
308
+ p12Password: 'test',
309
+ provisioningProfiles: [],
310
+ },
311
+ 'signing-certificates/ios/Prod-3/ios-signing-certificate.json': {
312
+ id: 3,
313
+ name: 'Prod',
314
+ p12File: 'ios-certificate.p12',
315
+ p12Password: 'test',
316
+ provisioningProfiles: ['Test.mobileprovision', '../../../../etc/hosts'],
317
+ },
318
+ 'signing-certificates/ios/Prod-3/ios-certificate.p12': Buffer.from('p12'),
319
+ 'signing-certificates/ios/Prod-3/Test.mobileprovision': Buffer.from('profile'),
320
+ });
321
+ const { apps } = await parseAppflowExport(exportDirectory);
322
+ const app = apps[0];
323
+ expect(app.certificates).toHaveLength(1);
324
+ expect(app.certificates[0].name).toBe('Prod');
325
+ expect(app.certificates[0].provisioningProfilePaths).toHaveLength(1);
326
+ expect(app.certificates[0].provisioningProfilePaths[0]).toContain('Test.mobileprovision');
327
+ expect(app.notes).toContainEqual(expect.stringContaining('`../../../outside.jks` is invalid'));
328
+ expect(app.notes).toContainEqual(expect.stringContaining('`/etc/hosts` is invalid'));
329
+ expect(app.notes).toContainEqual(expect.stringContaining('`../../../../etc/hosts`'));
330
+ });
331
+ it('should skip an app with an invalid app detail file', async () => {
332
+ writeAppFiles('Invalid-66666666', {
333
+ 'app-detail.json': 'not json',
334
+ });
335
+ const { apps, skippedApps } = await parseAppflowExport(exportDirectory);
336
+ expect(apps).toEqual([]);
337
+ expect(skippedApps).toHaveLength(1);
338
+ expect(skippedApps[0].retryLater).toBe(false);
339
+ });
340
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capawesome/cli",
3
- "version": "4.18.4",
3
+ "version": "4.19.0",
4
4
  "description": "The Capawesome Cloud Command Line Interface (CLI) to manage Live Updates and more.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -58,7 +58,7 @@
58
58
  "@clack/prompts": "0.7.0",
59
59
  "@napi-rs/keyring": "1.3.0",
60
60
  "@sentry/node": "10.58.0",
61
- "adm-zip": "0.6.0",
61
+ "adm-zip": "0.6.1",
62
62
  "axios": "1.18.1",
63
63
  "axios-retry": "4.5.0",
64
64
  "c12": "3.3.3",
@@ -81,14 +81,14 @@
81
81
  "@types/mime": "3.0.4",
82
82
  "@types/node": "24.2.1",
83
83
  "@types/semver": "7.5.8",
84
- "@vitest/coverage-v8": "4.1.7",
85
- "@vitest/ui": "4.1.7",
84
+ "@vitest/coverage-v8": "4.1.11",
85
+ "@vitest/ui": "4.1.11",
86
86
  "nock": "14.0.10",
87
87
  "prettier": "3.3.3",
88
88
  "rimraf": "6.0.1",
89
89
  "tsc-alias": "1.8.16",
90
90
  "typescript": "5.6.3",
91
- "vitest": "4.1.7"
91
+ "vitest": "4.1.11"
92
92
  },
93
93
  "prettier": "@ionic/prettier-config"
94
94
  }