@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,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
+ });
@@ -1,6 +1,16 @@
1
1
  import fs from 'fs';
2
2
  import mime from 'mime';
3
3
  import pathModule from 'path';
4
+ import { createBufferFromPath } from './buffer.js';
5
+ import { getCodeFromUnknownError, UserError } from './error.js';
6
+ const concurrentModificationHint = 'Make sure that no other process (e.g. a build or file sync client) modifies the folder while the command is running.';
7
+ const permissionHint = 'Make sure that you have permission to read the file.';
8
+ const unreadableFileErrorHints = {
9
+ EACCES: permissionHint,
10
+ EBUSY: concurrentModificationHint,
11
+ ENOENT: concurrentModificationHint,
12
+ EPERM: permissionHint,
13
+ };
4
14
  export const getFilesInDirectoryAndSubdirectories = async (path) => {
5
15
  const files = [];
6
16
  const walk = async (directory) => {
@@ -38,6 +48,23 @@ export const getFilesInDirectoryAndSubdirectories = async (path) => {
38
48
  await walk(path);
39
49
  return files;
40
50
  };
51
+ /**
52
+ * Reads a file that was found by `getFilesInDirectoryAndSubdirectories`.
53
+ * Such files can vanish, get locked or become inaccessible in the meantime (e.g. by a running build or a file sync client).
54
+ */
55
+ export const readFileFromDirectory = async (path) => {
56
+ try {
57
+ return await createBufferFromPath(path);
58
+ }
59
+ catch (error) {
60
+ const code = getCodeFromUnknownError(error);
61
+ const hint = code ? unreadableFileErrorHints[code] : undefined;
62
+ if (hint) {
63
+ throw new UserError(`The file could not be read: ${path}. ${hint}`);
64
+ }
65
+ throw error;
66
+ }
67
+ };
41
68
  export const directoryContainsSymlinks = async (path) => {
42
69
  const dirEntries = await fs.promises.readdir(path, { withFileTypes: true, recursive: true }).catch(() => []);
43
70
  return dirEntries.some((dirEntry) => dirEntry.isSymbolicLink());
@@ -0,0 +1,46 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import pathModule from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { UserError } from './error.js';
6
+ import { readFileFromDirectory } from './file.js';
7
+ const { mockCreateBufferFromPath } = vi.hoisted(() => ({ mockCreateBufferFromPath: vi.fn() }));
8
+ vi.mock('./buffer.js', async (importOriginal) => {
9
+ const actual = await importOriginal();
10
+ mockCreateBufferFromPath.mockImplementation(actual.createBufferFromPath);
11
+ return { ...actual, createBufferFromPath: mockCreateBufferFromPath };
12
+ });
13
+ const createErrorWithCode = (code) => Object.assign(new Error(code), { code });
14
+ describe('readFileFromDirectory', () => {
15
+ let directory;
16
+ beforeEach(() => {
17
+ directory = fs.mkdtempSync(pathModule.join(os.tmpdir(), 'file-'));
18
+ });
19
+ afterEach(() => {
20
+ fs.rmSync(directory, { force: true, recursive: true });
21
+ });
22
+ it('should read the file', async () => {
23
+ const path = pathModule.join(directory, 'index.html');
24
+ fs.writeFileSync(path, '<html></html>');
25
+ const buffer = await readFileFromDirectory(path);
26
+ expect(buffer.toString()).toBe('<html></html>');
27
+ });
28
+ it('should throw a user error if the file no longer exists', async () => {
29
+ const path = pathModule.join(directory, 'missing.png');
30
+ const promise = readFileFromDirectory(path);
31
+ await expect(promise).rejects.toThrow(UserError);
32
+ await expect(promise).rejects.toThrow(`The file could not be read: ${path}. Make sure that no other process`);
33
+ });
34
+ it('should throw a user error if the file is not readable', async () => {
35
+ const path = pathModule.join(directory, 'index.html');
36
+ mockCreateBufferFromPath.mockRejectedValueOnce(createErrorWithCode('EACCES'));
37
+ const promise = readFileFromDirectory(path);
38
+ await expect(promise).rejects.toThrow(UserError);
39
+ await expect(promise).rejects.toThrow(`The file could not be read: ${path}. Make sure that you have permission to read the file.`);
40
+ });
41
+ it('should rethrow errors that are not related to reading the file', async () => {
42
+ const error = createErrorWithCode('EISDIR');
43
+ mockCreateBufferFromPath.mockRejectedValueOnce(error);
44
+ await expect(readFileFromDirectory(pathModule.join(directory, 'assets'))).rejects.toBe(error);
45
+ });
46
+ });
@@ -1,6 +1,5 @@
1
1
  import { MANIFEST_JSON_FILE_NAME } from '../config/index.js';
2
- import { createBufferFromPath } from './buffer.js';
3
- import { getFilesInDirectoryAndSubdirectories, writeFile } from './file.js';
2
+ import { getFilesInDirectoryAndSubdirectories, readFileFromDirectory, writeFile } from './file.js';
4
3
  import { createHash } from './hash.js';
5
4
  const ignoreFiles = ['.DS_Store', MANIFEST_JSON_FILE_NAME];
6
5
  export const generateManifestJson = async (path) => {
@@ -9,7 +8,7 @@ export const generateManifestJson = async (path) => {
9
8
  const files = await getFilesInDirectoryAndSubdirectories(path);
10
9
  // Iterate over each file
11
10
  for (const [index, file] of files.entries()) {
12
- const fileBuffer = await createBufferFromPath(file.path);
11
+ const fileBuffer = await readFileFromDirectory(file.path);
13
12
  const checksum = await createHash(fileBuffer);
14
13
  const sizeInBytes = fileBuffer.byteLength;
15
14
  // Skip ignored files
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capawesome/cli",
3
- "version": "4.18.3",
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
  }